Skip to content
This repository was archived by the owner on Nov 1, 2023. It is now read-only.

Commit f922aa5

Browse files
Revert "Disable repro and debug VM CLI commands. (#3494)"
This reverts commit 7bcc41c.
1 parent acf7c7a commit f922aa5

File tree

3 files changed

+519
-9
lines changed

3 files changed

+519
-9
lines changed

src/cli/onefuzz/api.py

Lines changed: 314 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pkgutil
1010
import re
1111
import subprocess # nosec
12+
import time
1213
import uuid
1314
from enum import Enum
1415
from shutil import which
@@ -34,7 +35,8 @@
3435

3536
from .__version__ import __version__
3637
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
3840

3941
UUID_EXPANSION = TypeVar("UUID_EXPANSION", UUID, str)
4042

@@ -528,6 +530,316 @@ def _download_tasks(
528530
azcopy_sync(to_download[name], outdir)
529531

530532

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+
531843
class Notifications(Endpoint):
532844
"""Interact with models.Notifications"""
533845

@@ -1588,6 +1900,7 @@ def __init__(
15881900
client_secret=client_secret,
15891901
)
15901902
self.containers = Containers(self)
1903+
self.repro = Repro(self)
15911904
self.notifications = Notifications(self)
15921905
self.tasks = Tasks(self)
15931906
self.jobs = Jobs(self)

0 commit comments

Comments
 (0)