|
9 | 9 | import pkgutil |
10 | 10 | import re |
11 | 11 | import subprocess # nosec |
| 12 | +import time |
12 | 13 | import uuid |
13 | 14 | from enum import Enum |
14 | 15 | from shutil import which |
|
34 | 35 |
|
35 | 36 | from .__version__ import __version__ |
36 | 37 | from .azcopy import azcopy_sync |
37 | | -from .backend import Backend, BackendConfig, ContainerWrapper |
| 38 | +from .backend import Backend, BackendConfig, ContainerWrapper, wait |
| 39 | +from .ssh import build_ssh_command, ssh_connect, temp_file |
38 | 40 |
|
39 | 41 | UUID_EXPANSION = TypeVar("UUID_EXPANSION", UUID, str) |
40 | 42 |
|
@@ -528,6 +530,316 @@ def _download_tasks( |
528 | 530 | azcopy_sync(to_download[name], outdir) |
529 | 531 |
|
530 | 532 |
|
| 533 | +class Repro(Endpoint): |
| 534 | + """Interact with Reproduction VMs""" |
| 535 | + |
| 536 | + endpoint = "repro_vms" |
| 537 | + |
| 538 | + def get(self, vm_id: UUID_EXPANSION) -> models.Repro: |
| 539 | + """get information about a Reproduction VM""" |
| 540 | + vm_id_expanded = self._disambiguate_uuid( |
| 541 | + "vm_id", vm_id, lambda: [str(x.vm_id) for x in self.list()] |
| 542 | + ) |
| 543 | + |
| 544 | + self.logger.debug("get repro vm: %s", vm_id_expanded) |
| 545 | + return self._req_model( |
| 546 | + "GET", models.Repro, data=requests.ReproGet(vm_id=vm_id_expanded) |
| 547 | + ) |
| 548 | + |
| 549 | + def get_files( |
| 550 | + self, |
| 551 | + report_container: primitives.Container, |
| 552 | + report_name: str, |
| 553 | + include_setup: bool = False, |
| 554 | + output_dir: primitives.Directory = primitives.Directory("."), |
| 555 | + ) -> None: |
| 556 | + """downloads the files necessary to locally repro the crash from a given report""" |
| 557 | + report_bytes = self.onefuzz.containers.files.get(report_container, report_name) |
| 558 | + report = json.loads(report_bytes) |
| 559 | + |
| 560 | + crash_info = { |
| 561 | + "input_blob_container": primitives.Container(""), |
| 562 | + "input_blob_name": "", |
| 563 | + "job_id": "", |
| 564 | + } |
| 565 | + if "input_blob" in report: |
| 566 | + crash_info["input_blob_container"] = report["input_blob"]["container"] |
| 567 | + crash_info["input_blob_name"] = report["input_blob"]["name"] |
| 568 | + crash_info["job_id"] = report["job_id"] |
| 569 | + elif "crash_test_result" in report and "original_crash_test_result" in report: |
| 570 | + if report["original_crash_test_result"]["crash_report"] is None: |
| 571 | + self.logger.error( |
| 572 | + "No crash report found in the original crash test result, repro files cannot be retrieved" |
| 573 | + ) |
| 574 | + return |
| 575 | + elif report["crash_test_result"]["crash_report"] is None: |
| 576 | + self.logger.info( |
| 577 | + "No crash report found in the new crash test result, falling back on the original crash test result for job_id" |
| 578 | + "Note: if using --include_setup, the downloaded fuzzer binaries may be out-of-date" |
| 579 | + ) |
| 580 | + |
| 581 | + original_report = report["original_crash_test_result"]["crash_report"] |
| 582 | + new_report = ( |
| 583 | + report["crash_test_result"]["crash_report"] or original_report |
| 584 | + ) # fallback on original_report |
| 585 | + |
| 586 | + crash_info["input_blob_container"] = original_report["input_blob"][ |
| 587 | + "container" |
| 588 | + ] |
| 589 | + crash_info["input_blob_name"] = original_report["input_blob"]["name"] |
| 590 | + crash_info["job_id"] = new_report["job_id"] |
| 591 | + else: |
| 592 | + self.logger.error( |
| 593 | + "Encountered an unhandled report format, repro files cannot be retrieved" |
| 594 | + ) |
| 595 | + return |
| 596 | + |
| 597 | + self.logger.info( |
| 598 | + "downloading files necessary to locally repro crash %s", |
| 599 | + crash_info["input_blob_name"], |
| 600 | + ) |
| 601 | + self.onefuzz.containers.files.download( |
| 602 | + primitives.Container(crash_info["input_blob_container"]), |
| 603 | + crash_info["input_blob_name"], |
| 604 | + os.path.join(output_dir, crash_info["input_blob_name"]), |
| 605 | + ) |
| 606 | + |
| 607 | + if include_setup: |
| 608 | + setup_container = list( |
| 609 | + self.onefuzz.jobs.containers.list( |
| 610 | + crash_info["job_id"], enums.ContainerType.setup |
| 611 | + ) |
| 612 | + )[0] |
| 613 | + |
| 614 | + self.onefuzz.containers.files.download_dir( |
| 615 | + primitives.Container(setup_container), output_dir |
| 616 | + ) |
| 617 | + |
| 618 | + def create( |
| 619 | + self, container: primitives.Container, path: str, duration: int = 24 |
| 620 | + ) -> models.Repro: |
| 621 | + """Create a Reproduction VM from a Crash Report""" |
| 622 | + self.logger.info( |
| 623 | + "creating repro vm: %s %s (%d hours)", container, path, duration |
| 624 | + ) |
| 625 | + return self._req_model( |
| 626 | + "POST", |
| 627 | + models.Repro, |
| 628 | + data=models.ReproConfig(container=container, path=path, duration=duration), |
| 629 | + ) |
| 630 | + |
| 631 | + def delete(self, vm_id: UUID_EXPANSION) -> models.Repro: |
| 632 | + """Delete a Reproduction VM""" |
| 633 | + vm_id_expanded = self._disambiguate_uuid( |
| 634 | + "vm_id", vm_id, lambda: [str(x.vm_id) for x in self.list()] |
| 635 | + ) |
| 636 | + |
| 637 | + self.logger.debug("deleting repro vm: %s", vm_id_expanded) |
| 638 | + return self._req_model( |
| 639 | + "DELETE", models.Repro, data=requests.ReproGet(vm_id=vm_id_expanded) |
| 640 | + ) |
| 641 | + |
| 642 | + def list(self) -> List[models.Repro]: |
| 643 | + """List all VMs""" |
| 644 | + self.logger.debug("listing repro vms") |
| 645 | + return self._req_model_list("GET", models.Repro, data=requests.ReproGet()) |
| 646 | + |
| 647 | + def _dbg_linux( |
| 648 | + self, repro: models.Repro, debug_command: Optional[str] |
| 649 | + ) -> Optional[str]: |
| 650 | + """Launch gdb with GDB script that includes 'target remote | ssh ...'""" |
| 651 | + |
| 652 | + if ( |
| 653 | + repro.auth is None |
| 654 | + or repro.ip is None |
| 655 | + or repro.state != enums.VmState.running |
| 656 | + ): |
| 657 | + raise Exception("vm setup failed: %s" % repro.state) |
| 658 | + |
| 659 | + with build_ssh_command( |
| 660 | + repro.ip, repro.auth.private_key, command="-T" |
| 661 | + ) as ssh_cmd: |
| 662 | + gdb_script = [ |
| 663 | + "target remote | %s sudo /onefuzz/bin/repro-stdout.sh" |
| 664 | + % " ".join(ssh_cmd) |
| 665 | + ] |
| 666 | + |
| 667 | + if debug_command: |
| 668 | + gdb_script += [debug_command, "quit"] |
| 669 | + |
| 670 | + with temp_file("gdb.script", "\n".join(gdb_script)) as gdb_script_path: |
| 671 | + dbg = ["gdb", "--silent", "--command", gdb_script_path] |
| 672 | + |
| 673 | + if debug_command: |
| 674 | + dbg += ["--batch"] |
| 675 | + |
| 676 | + try: |
| 677 | + # security note: dbg is built from content coming from |
| 678 | + # the server, which is trusted in this context. |
| 679 | + return subprocess.run( # nosec |
| 680 | + dbg, stdout=subprocess.PIPE, stderr=subprocess.STDOUT |
| 681 | + ).stdout.decode(errors="ignore") |
| 682 | + except subprocess.CalledProcessError as err: |
| 683 | + self.logger.error( |
| 684 | + "debug failed: %s", err.output.decode(errors="ignore") |
| 685 | + ) |
| 686 | + raise err |
| 687 | + else: |
| 688 | + # security note: dbg is built from content coming from the |
| 689 | + # server, which is trusted in this context. |
| 690 | + subprocess.call(dbg) # nosec |
| 691 | + return None |
| 692 | + |
| 693 | + def _dbg_windows( |
| 694 | + self, |
| 695 | + repro: models.Repro, |
| 696 | + debug_command: Optional[str], |
| 697 | + retry_limit: Optional[int], |
| 698 | + ) -> Optional[str]: |
| 699 | + """Setup an SSH tunnel, then connect via CDB over SSH tunnel""" |
| 700 | + |
| 701 | + if ( |
| 702 | + repro.auth is None |
| 703 | + or repro.ip is None |
| 704 | + or repro.state != enums.VmState.running |
| 705 | + ): |
| 706 | + raise Exception("vm setup failed: %s" % repro.state) |
| 707 | + |
| 708 | + retry_count = 0 |
| 709 | + bind_all = which("wslpath") is not None and repro.os == enums.OS.windows |
| 710 | + proxy = "*:" + REPRO_SSH_FORWARD if bind_all else REPRO_SSH_FORWARD |
| 711 | + while retry_limit is None or retry_count <= retry_limit: |
| 712 | + if retry_limit: |
| 713 | + retry_count = retry_count + 1 |
| 714 | + with ssh_connect(repro.ip, repro.auth.private_key, proxy=proxy): |
| 715 | + dbg = ["cdb.exe", "-remote", "tcp:port=1337,server=localhost"] |
| 716 | + if debug_command: |
| 717 | + dbg_script = [debug_command, "qq"] |
| 718 | + with temp_file( |
| 719 | + "db.script", "\r\n".join(dbg_script) |
| 720 | + ) as dbg_script_path: |
| 721 | + dbg += ["-cf", _wsl_path(dbg_script_path)] |
| 722 | + |
| 723 | + logging.debug("launching: %s", dbg) |
| 724 | + try: |
| 725 | + # security note: dbg is built from content coming from the server, |
| 726 | + # which is trusted in this context. |
| 727 | + return subprocess.run( # nosec |
| 728 | + dbg, stdout=subprocess.PIPE, stderr=subprocess.STDOUT |
| 729 | + ).stdout.decode(errors="ignore") |
| 730 | + except subprocess.CalledProcessError as err: |
| 731 | + if err.returncode == 0x8007274D: |
| 732 | + self.logger.info( |
| 733 | + "failed to connect to debug-server trying again in 10 seconds..." |
| 734 | + ) |
| 735 | + time.sleep(10.0) |
| 736 | + else: |
| 737 | + self.logger.error( |
| 738 | + "debug failed: %s", |
| 739 | + err.output.decode(errors="ignore"), |
| 740 | + ) |
| 741 | + raise err |
| 742 | + else: |
| 743 | + logging.debug("launching: %s", dbg) |
| 744 | + # security note: dbg is built from content coming from the |
| 745 | + # server, which is trusted in this context. |
| 746 | + try: |
| 747 | + subprocess.check_call(dbg) # nosec |
| 748 | + return None |
| 749 | + except subprocess.CalledProcessError as err: |
| 750 | + if err.returncode == 0x8007274D: |
| 751 | + self.logger.info( |
| 752 | + "failed to connect to debug-server trying again in 10 seconds..." |
| 753 | + ) |
| 754 | + time.sleep(10.0) |
| 755 | + else: |
| 756 | + return None |
| 757 | + |
| 758 | + if retry_limit is not None: |
| 759 | + self.logger.info( |
| 760 | + f"failed to connect to debug-server after {retry_limit} attempts. Please try again later " |
| 761 | + + f"with onefuzz debug connect {repro.vm_id}" |
| 762 | + ) |
| 763 | + return None |
| 764 | + |
| 765 | + def connect( |
| 766 | + self, |
| 767 | + vm_id: UUID_EXPANSION, |
| 768 | + delete_after_use: bool = False, |
| 769 | + debug_command: Optional[str] = None, |
| 770 | + retry_limit: Optional[int] = None, |
| 771 | + ) -> Optional[str]: |
| 772 | + """Connect to an existing Reproduction VM""" |
| 773 | + |
| 774 | + self.logger.info("connecting to reproduction VM: %s", vm_id) |
| 775 | + |
| 776 | + if which("ssh") is None: |
| 777 | + raise Exception("unable to find ssh on local machine") |
| 778 | + |
| 779 | + def missing_os() -> Tuple[bool, str, models.Repro]: |
| 780 | + repro = self.get(vm_id) |
| 781 | + return ( |
| 782 | + repro.os is not None, |
| 783 | + "waiting for os determination", |
| 784 | + repro, |
| 785 | + ) |
| 786 | + |
| 787 | + repro = wait(missing_os) |
| 788 | + |
| 789 | + if repro.os == enums.OS.windows: |
| 790 | + if which("cdb.exe") is None: |
| 791 | + raise Exception("unable to find cdb.exe on local machine") |
| 792 | + if repro.os == enums.OS.linux: |
| 793 | + if which("gdb") is None: |
| 794 | + raise Exception("unable to find gdb on local machine") |
| 795 | + |
| 796 | + def func() -> Tuple[bool, str, models.Repro]: |
| 797 | + repro = self.get(vm_id) |
| 798 | + state = repro.state |
| 799 | + return ( |
| 800 | + repro.auth is not None |
| 801 | + and repro.ip is not None |
| 802 | + and state not in [enums.VmState.init, enums.VmState.extensions_launch], |
| 803 | + "launching reproducing vm. current state: %s" % state, |
| 804 | + repro, |
| 805 | + ) |
| 806 | + |
| 807 | + repro = wait(func) |
| 808 | + # give time for debug server to initialize |
| 809 | + time.sleep(30.0) |
| 810 | + result: Optional[str] = None |
| 811 | + if repro.os == enums.OS.windows: |
| 812 | + result = self._dbg_windows(repro, debug_command, retry_limit) |
| 813 | + elif repro.os == enums.OS.linux: |
| 814 | + result = self._dbg_linux(repro, debug_command) |
| 815 | + else: |
| 816 | + raise NotImplementedError |
| 817 | + |
| 818 | + if delete_after_use: |
| 819 | + self.logger.debug("deleting vm %s", repro.vm_id) |
| 820 | + self.delete(repro.vm_id) |
| 821 | + |
| 822 | + return result |
| 823 | + |
| 824 | + def create_and_connect( |
| 825 | + self, |
| 826 | + container: primitives.Container, |
| 827 | + path: str, |
| 828 | + duration: int = 24, |
| 829 | + delete_after_use: bool = False, |
| 830 | + debug_command: Optional[str] = None, |
| 831 | + retry_limit: Optional[int] = None, |
| 832 | + ) -> Optional[str]: |
| 833 | + """Create and connect to a Reproduction VM""" |
| 834 | + repro = self.create(container, path, duration=duration) |
| 835 | + return self.connect( |
| 836 | + repro.vm_id, |
| 837 | + delete_after_use=delete_after_use, |
| 838 | + debug_command=debug_command, |
| 839 | + retry_limit=retry_limit, |
| 840 | + ) |
| 841 | + |
| 842 | + |
531 | 843 | class Notifications(Endpoint): |
532 | 844 | """Interact with models.Notifications""" |
533 | 845 |
|
@@ -1588,6 +1900,7 @@ def __init__( |
1588 | 1900 | client_secret=client_secret, |
1589 | 1901 | ) |
1590 | 1902 | self.containers = Containers(self) |
| 1903 | + self.repro = Repro(self) |
1591 | 1904 | self.notifications = Notifications(self) |
1592 | 1905 | self.tasks = Tasks(self) |
1593 | 1906 | self.jobs = Jobs(self) |
|
0 commit comments