|
1 | 1 | import unittest |
2 | 2 | import os |
| 3 | +import json |
3 | 4 | import textwrap |
4 | 5 | import contextlib |
5 | 6 | import importlib |
@@ -418,6 +419,164 @@ def _frame_to_lineno_tuple(frame): |
418 | 419 | filename, location, funcname, opcode = frame |
419 | 420 | return (filename, location.lineno, funcname, opcode) |
420 | 421 |
|
| 422 | + @contextmanager |
| 423 | + def _poisoned_debug_offsets_process(self, poison_code): |
| 424 | + script = ( |
| 425 | + textwrap.dedent( |
| 426 | + """\ |
| 427 | + import json |
| 428 | + import os |
| 429 | + import struct |
| 430 | + import sys |
| 431 | + import threading |
| 432 | + import time |
| 433 | +
|
| 434 | + cookie = b"xdebugpy" |
| 435 | + pid = os.getpid() |
| 436 | +
|
| 437 | + def find_section_address(section_name, filename_substr): |
| 438 | + ehdr_fmt = "<16sHHIQQQIHHHHHH" |
| 439 | + phdr_fmt = "<IIQQQQQQ" |
| 440 | + shdr_fmt = "<IIQQQQIIQQ" |
| 441 | +
|
| 442 | + def search_elf_file_for_section(start_address, elf_file): |
| 443 | + with open(elf_file, "rb") as f: |
| 444 | + data = f.read() |
| 445 | + if data[:4] != b"\\x7fELF" or data[4] != 2 or data[5] != 1: |
| 446 | + raise RuntimeError(f"unsupported ELF file: {elf_file}") |
| 447 | +
|
| 448 | + ehdr = struct.unpack_from(ehdr_fmt, data, 0) |
| 449 | + e_phoff = ehdr[5] |
| 450 | + e_shoff = ehdr[6] |
| 451 | + e_phentsize = ehdr[9] |
| 452 | + e_phnum = ehdr[10] |
| 453 | + e_shentsize = ehdr[11] |
| 454 | + e_shnum = ehdr[12] |
| 455 | + e_shstrndx = ehdr[13] |
| 456 | +
|
| 457 | + shstr = struct.unpack_from( |
| 458 | + shdr_fmt, data, e_shoff + e_shstrndx * e_shentsize |
| 459 | + ) |
| 460 | + shstrtab = data[shstr[4]:shstr[4] + shstr[5]] |
| 461 | +
|
| 462 | + section = None |
| 463 | + for i in range(e_shnum): |
| 464 | + shdr = struct.unpack_from( |
| 465 | + shdr_fmt, data, e_shoff + i * e_shentsize |
| 466 | + ) |
| 467 | + name_end = shstrtab.find(b"\\0", shdr[0]) |
| 468 | + name = shstrtab[shdr[0]:name_end].decode() |
| 469 | + if name.lstrip(".") == section_name: |
| 470 | + section = shdr |
| 471 | + break |
| 472 | + if section is None: |
| 473 | + return 0 |
| 474 | +
|
| 475 | + first_load = None |
| 476 | + for i in range(e_phnum): |
| 477 | + phdr = struct.unpack_from( |
| 478 | + phdr_fmt, data, e_phoff + i * e_phentsize |
| 479 | + ) |
| 480 | + if phdr[0] == 1: |
| 481 | + first_load = phdr |
| 482 | + break |
| 483 | + if first_load is None: |
| 484 | + raise RuntimeError(f"no PT_LOAD segment in {elf_file}") |
| 485 | +
|
| 486 | + p_vaddr = first_load[3] |
| 487 | + p_align = first_load[7] or 1 |
| 488 | + elf_load_addr = p_vaddr - (p_vaddr % p_align) |
| 489 | + return start_address + section[3] - elf_load_addr |
| 490 | +
|
| 491 | + with open(f"/proc/{pid}/maps") as f: |
| 492 | + for line in f: |
| 493 | + parts = line.split(None, 5) |
| 494 | + if len(parts) < 6: |
| 495 | + continue |
| 496 | + path = parts[5].strip() |
| 497 | + if path.startswith("["): |
| 498 | + continue |
| 499 | + if filename_substr not in os.path.basename(path): |
| 500 | + continue |
| 501 | + start = int(parts[0].split("-")[0], 16) |
| 502 | + try: |
| 503 | + addr = search_elf_file_for_section(start, path) |
| 504 | + except OSError: |
| 505 | + continue |
| 506 | + if addr: |
| 507 | + return addr |
| 508 | + raise RuntimeError(f"{section_name} section not found") |
| 509 | +
|
| 510 | + def find_debug_offsets(): |
| 511 | + with open(f"/proc/{pid}/maps") as f: |
| 512 | + for line in f: |
| 513 | + parts = line.split() |
| 514 | + if len(parts) < 2 or "rw" not in parts[1]: |
| 515 | + continue |
| 516 | + start, end = (int(x, 16) for x in parts[0].split("-")) |
| 517 | + if end - start > 10_000_000: |
| 518 | + continue |
| 519 | + try: |
| 520 | + fd = os.open(f"/proc/{pid}/mem", os.O_RDONLY) |
| 521 | + os.lseek(fd, start, 0) |
| 522 | + data = os.read(fd, end - start) |
| 523 | + os.close(fd) |
| 524 | + except OSError: |
| 525 | + continue |
| 526 | + off = data.find(cookie) |
| 527 | + if off == -1: |
| 528 | + continue |
| 529 | + version = struct.unpack_from("<Q", data, off + 8)[0] |
| 530 | + if ((version >> 24) & 0xFF) != sys.version_info.major: |
| 531 | + continue |
| 532 | + return start + off |
| 533 | + raise RuntimeError("debug offsets not found") |
| 534 | +
|
| 535 | + addr = find_debug_offsets() |
| 536 | + """ |
| 537 | + ) |
| 538 | + + textwrap.dedent(poison_code) |
| 539 | + + "\n" |
| 540 | + + textwrap.dedent( |
| 541 | + """\ |
| 542 | + print( |
| 543 | + json.dumps({"pid": pid, "native_tid": threading.get_native_id()}), |
| 544 | + flush=True, |
| 545 | + ) |
| 546 | + time.sleep(60) |
| 547 | + """ |
| 548 | + ) |
| 549 | + ) |
| 550 | + |
| 551 | + proc = subprocess.Popen( |
| 552 | + [sys.executable, "-c", script], |
| 553 | + stdout=subprocess.PIPE, |
| 554 | + stderr=subprocess.PIPE, |
| 555 | + text=True, |
| 556 | + ) |
| 557 | + try: |
| 558 | + line = proc.stdout.readline() |
| 559 | + if not line: |
| 560 | + stderr = proc.stderr.read() |
| 561 | + self.fail( |
| 562 | + "poisoned child failed to initialize: " |
| 563 | + f"{stderr.strip() or 'no stderr output'}" |
| 564 | + ) |
| 565 | + yield proc, json.loads(line) |
| 566 | + finally: |
| 567 | + try: |
| 568 | + proc.terminate() |
| 569 | + proc.wait(timeout=SHORT_TIMEOUT) |
| 570 | + except subprocess.TimeoutExpired: |
| 571 | + proc.kill() |
| 572 | + proc.wait(timeout=SHORT_TIMEOUT) |
| 573 | + except OSError: |
| 574 | + pass |
| 575 | + if proc.stdout is not None: |
| 576 | + proc.stdout.close() |
| 577 | + if proc.stderr is not None: |
| 578 | + proc.stderr.close() |
| 579 | + |
421 | 580 | def _extract_coroutine_stacks_lineno_only(self, stack_trace): |
422 | 581 | """Extract coroutine stacks with line numbers only (no column offsets). |
423 | 582 |
|
@@ -3574,5 +3733,114 @@ def test_get_stats_disabled_raises(self): |
3574 | 3733 | client_socket.sendall(b"done") |
3575 | 3734 |
|
3576 | 3735 |
|
| 3736 | +@requires_remote_subprocess_debugging() |
| 3737 | +@unittest.skipUnless(sys.platform == "linux", "requires /proc/self/mem") |
| 3738 | +@unittest.skipIf( |
| 3739 | + not PROCESS_VM_READV_SUPPORTED, |
| 3740 | + "Test only runs on Linux with process_vm_readv support", |
| 3741 | +) |
| 3742 | +class TestInvalidDebugOffsets(RemoteInspectionTestBase): |
| 3743 | + @skip_if_not_supported |
| 3744 | + def test_rejects_poisoned_thread_state_size(self): |
| 3745 | + poison_code = textwrap.dedent( |
| 3746 | + """\ |
| 3747 | + thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8 |
| 3748 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3749 | + os.lseek(fd, addr + thread_state_offset, 0) |
| 3750 | + os.write(fd, struct.pack("<Q", 0xFFFFFFFFFFFFFFFF)) |
| 3751 | + os.close(fd) |
| 3752 | + """ |
| 3753 | + ) |
| 3754 | + |
| 3755 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3756 | + with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"): |
| 3757 | + RemoteUnwinder(info["pid"], all_threads=True, debug=True) |
| 3758 | + |
| 3759 | + @skip_if_not_supported |
| 3760 | + def test_rejects_poisoned_thread_state_offset(self): |
| 3761 | + poison_code = textwrap.dedent( |
| 3762 | + """\ |
| 3763 | + thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8 |
| 3764 | + native_thread_id_offset = thread_state_offset + 64 |
| 3765 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3766 | + os.lseek(fd, addr + native_thread_id_offset, 0) |
| 3767 | + os.write(fd, struct.pack("<Q", 2000)) |
| 3768 | + os.close(fd) |
| 3769 | + """ |
| 3770 | + ) |
| 3771 | + |
| 3772 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3773 | + with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"): |
| 3774 | + RemoteUnwinder(info["pid"], all_threads=True, debug=True) |
| 3775 | + |
| 3776 | + @skip_if_not_supported |
| 3777 | + def test_rejects_poisoned_runtime_state_interpreters_head(self): |
| 3778 | + poison_code = textwrap.dedent( |
| 3779 | + """\ |
| 3780 | + interpreters_head_offset = 8 + 8 + 8 + 8 + 8 |
| 3781 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3782 | + os.lseek(fd, addr + interpreters_head_offset, 0) |
| 3783 | + os.write(fd, struct.pack("<Q", 0xFFFFFFFFFFFFFFFF)) |
| 3784 | + os.close(fd) |
| 3785 | + """ |
| 3786 | + ) |
| 3787 | + |
| 3788 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3789 | + with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"): |
| 3790 | + RemoteUnwinder(info["pid"], all_threads=True, debug=True) |
| 3791 | + |
| 3792 | + @skip_if_not_supported |
| 3793 | + def test_rejects_poisoned_thread_state_datastack_chunk(self): |
| 3794 | + poison_code = textwrap.dedent( |
| 3795 | + """\ |
| 3796 | + thread_state_offset = 8 + 8 + 8 + 3 * 8 + 16 * 8 |
| 3797 | + datastack_chunk_offset = thread_state_offset + 72 |
| 3798 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3799 | + os.lseek(fd, addr + datastack_chunk_offset, 0) |
| 3800 | + os.write(fd, struct.pack("<Q", 0x10000)) |
| 3801 | + os.close(fd) |
| 3802 | + """ |
| 3803 | + ) |
| 3804 | + |
| 3805 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3806 | + with self.assertRaisesRegex(RuntimeError, "Invalid debug offsets"): |
| 3807 | + RemoteUnwinder(info["pid"], all_threads=True, debug=True) |
| 3808 | + |
| 3809 | + @skip_if_not_supported |
| 3810 | + def test_rejects_poisoned_asyncio_task_node_offset(self): |
| 3811 | + poison_code = textwrap.dedent( |
| 3812 | + """\ |
| 3813 | + asyncio_addr = find_section_address("AsyncioDebug", "python") |
| 3814 | + task_node_offset = 6 * 8 |
| 3815 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3816 | + os.lseek(fd, asyncio_addr + task_node_offset, 0) |
| 3817 | + os.write(fd, struct.pack("<Q", 0x10000)) |
| 3818 | + os.close(fd) |
| 3819 | + """ |
| 3820 | + ) |
| 3821 | + |
| 3822 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3823 | + with self.assertRaisesRegex(RuntimeError, "Invalid AsyncioDebug offsets"): |
| 3824 | + RemoteUnwinder(info["pid"], debug=True) |
| 3825 | + |
| 3826 | + @skip_if_not_supported |
| 3827 | + def test_rejects_poisoned_asyncio_running_task_offset(self): |
| 3828 | + poison_code = textwrap.dedent( |
| 3829 | + """\ |
| 3830 | + asyncio_addr = find_section_address("AsyncioDebug", "python") |
| 3831 | + asyncio_thread_state_offset = 7 * 8 + 2 * 8 |
| 3832 | + asyncio_running_task_offset = asyncio_thread_state_offset + 2 * 8 |
| 3833 | + fd = os.open(f"/proc/{pid}/mem", os.O_WRONLY) |
| 3834 | + os.lseek(fd, asyncio_addr + asyncio_running_task_offset, 0) |
| 3835 | + os.write(fd, struct.pack("<Q", 0x10000)) |
| 3836 | + os.close(fd) |
| 3837 | + """ |
| 3838 | + ) |
| 3839 | + |
| 3840 | + with self._poisoned_debug_offsets_process(poison_code) as (_, info): |
| 3841 | + with self.assertRaisesRegex(RuntimeError, "Invalid AsyncioDebug offsets"): |
| 3842 | + RemoteUnwinder(info["pid"], debug=True) |
| 3843 | + |
| 3844 | + |
3577 | 3845 | if __name__ == "__main__": |
3578 | 3846 | unittest.main() |
0 commit comments