| 1 | """ |
| 2 | Cyclops v2 - Pillar 3, the dynamic oracle. |
| 3 | |
| 4 | The ONLY component allowed to declare a finding real. It does not read code and |
| 5 | opine; it executes and observes. For the memory-corruption / parser archetype it: |
| 6 | 1. compiles a libFuzzer harness against the target with ASan/UBSan, |
| 7 | 2. fuzzes for a time budget, |
| 8 | 3. on a crash: saves the reproducer + sanitizer report, |
| 9 | 4. TRIAGES the crash (write vs read primitive, PC control, exploitability), |
| 10 | 5. writes a Proof node and - only if the triage clears the severity bar - |
| 11 | promotes the hypothesis to a Finding through memory's gate. |
| 12 | |
| 13 | A crash is ground truth. memory.promote_to_finding refuses anything without it. |
| 14 | """ |
| 15 | import os, sys, re, subprocess, glob, shutil, time |
| 16 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "memory")) |
| 17 | from memory import Memory |
| 18 | |
| 19 | CYCLOPS_HOME = os.environ.get("CYCLOPS_HOME", "/opt/cyclops-v2") |
| 20 | CRASH_DIR = os.path.join(CYCLOPS_HOME, "oracle", "crashes") |
| 21 | CLANG = os.environ.get("CYCLOPS_CLANG", "clang") |
| 22 | |
| 23 | ASAN_CLASS = [ |
| 24 | (r"heap-buffer-overflow.*WRITE", ("heap OOB WRITE", "CRITICAL")), |
| 25 | (r"stack-buffer-overflow.*WRITE", ("stack OOB WRITE", "CRITICAL")), |
| 26 | (r"global-buffer-overflow.*WRITE", ("global OOB WRITE", "HIGH")), |
| 27 | (r"heap-use-after-free", ("use-after-free", "CRITICAL")), |
| 28 | (r"double-free", ("double-free", "HIGH")), |
| 29 | (r"WRITE of size", ("OOB WRITE", "CRITICAL")), |
| 30 | (r"heap-buffer-overflow.*READ", ("heap OOB READ", "MEDIUM")), |
| 31 | (r"stack-buffer-overflow.*READ", ("stack OOB READ", "MEDIUM")), |
| 32 | (r"SEGV on unknown address", ("SEGV", "HIGH")), |
| 33 | (r"stack-overflow", ("stack exhaustion", "LOW")), |
| 34 | (r"out-of-memory", ("OOM", "LOW")), |
| 35 | ] |
| 36 | |
| 37 | |
| 38 | def compile_harness(harness_c, target_srcs, out_bin, extra_flags=None): |
| 39 | """clang -fsanitize=fuzzer,address. Returns (ok, log).""" |
| 40 | cmd = [CLANG, "-g", "-O1", "-fsanitize=fuzzer,address,undefined", |
| 41 | "-fno-omit-frame-pointer", harness_c] + list(target_srcs) + \ |
| 42 | (extra_flags or []) + ["-o", out_bin] |
| 43 | p = subprocess.run(cmd, capture_output=True, text=True) |
| 44 | return p.returncode == 0, (p.stderr or "") + (p.stdout or "") |
| 45 | |
| 46 | |
| 47 | def triage(asan_text): |
| 48 | """Classify the sanitizer report -> (primitive, severity, exploitable).""" |
| 49 | for pat, (prim, sev) in ASAN_CLASS: |
| 50 | if re.search(pat, asan_text, re.I): |
| 51 | exploitable = sev in ("CRITICAL", "HIGH") and "WRITE" in prim.upper() \ |
| 52 | or "use-after-free" in prim or prim == "SEGV" |
| 53 | return prim, sev, exploitable |
| 54 | return "unknown-fault", "MEDIUM", False |
| 55 | |
| 56 | |
| 57 | def run_fuzz(fuzz_bin, seconds=120, corpus=None): |
| 58 | """Run libFuzzer. Returns (crashed, crash_input_path, asan_text).""" |
| 59 | os.makedirs(CRASH_DIR, exist_ok=True) |
| 60 | artifact = os.path.join(CRASH_DIR, "crash-") |
| 61 | args = [fuzz_bin, f"-max_total_time={seconds}", "-print_final_stats=1", |
| 62 | f"-artifact_prefix={artifact}"] |
| 63 | if corpus: |
| 64 | args.append(corpus) |
| 65 | p = subprocess.run(args, capture_output=True, text=True) |
| 66 | txt = (p.stderr or "") + (p.stdout or "") |
| 67 | crashed = ("ERROR: AddressSanitizer" in txt or "ERROR: libFuzzer" in txt |
| 68 | or "SUMMARY: " in txt or "runtime error:" in txt) |
| 69 | crash_input = None |
| 70 | found = sorted(glob.glob(artifact + "*")) |
| 71 | if found: |
| 72 | crash_input = found[-1] |
| 73 | return crashed, crash_input, txt |
| 74 | |
| 75 | |
| 76 | def prove_memory_corruption(target_id, hypothesis_id, impact_family, |
| 77 | harness_c, target_srcs, extra_flags=None, |
| 78 | seconds=120, repo=""): |
| 79 | """Full oracle run for one parser target. Returns a result dict.""" |
| 80 | m = Memory() |
| 81 | os.makedirs(CRASH_DIR, exist_ok=True) |
| 82 | out_bin = os.path.join(CRASH_DIR, f"fuzz_{target_id[:8]}") |
| 83 | ok, log = compile_harness(harness_c, target_srcs, out_bin, extra_flags) |
| 84 | if not ok: |
| 85 | return {"status": "BUILD_FAILED", "log": log[-2000:]} |
| 86 | |
| 87 | crashed, crash_input, asan = run_fuzz(out_bin, seconds=seconds) |
| 88 | if not crashed: |
| 89 | m.add_proof(hypothesis_id, "DISPROVEN", "libfuzzer+asan", "", |
| 90 | f"fuzzed {seconds}s, no crash reached the sink") |
| 91 | return {"status": "NO_CRASH", "seconds": seconds} |
| 92 | |
| 93 | prim, sev, exploitable = triage(asan) |
| 94 | report_path = os.path.join(CRASH_DIR, f"report_{target_id[:8]}.txt") |
| 95 | with open(report_path, "w") as fh: |
| 96 | fh.write(asan) |
| 97 | summary = f"{prim}; ASan-confirmed; reproducer={crash_input}" |
| 98 | pid = m.add_proof(hypothesis_id, "REPLICA_CONFIRMED", "libfuzzer+asan", |
| 99 | report_path, summary) |
| 100 | |
| 101 | result = {"status": "CRASH", "primitive": prim, "severity": sev, |
| 102 | "exploitable": exploitable, "reproducer": crash_input, |
| 103 | "report": report_path, "proof": pid} |
| 104 | |
| 105 | if exploitable: |
| 106 | fid = m.promote_to_finding( |
| 107 | hypothesis_id, impact_family, sev, |
| 108 | f"{repo}: {prim} in parser -> {impact_family}", |
| 109 | novel=False, cve_checked=False) |
| 110 | result["finding"] = fid |
| 111 | else: |
| 112 | m.clear_axis(target_id, "memory-corruption", f"{prim}@{hypothesis_id[:8]}", |
| 113 | "LOW", f"{prim} reproduced but not an RCE-capable primitive") |
| 114 | result["note"] = "crash is not RCE-capable; logged as low axis, not a P1 finding" |
| 115 | return result |
| 116 | |
| 117 | |
| 118 | if __name__ == "__main__": |
| 119 | print("oracle module - invoked by orchestrator. See prove_memory_corruption().") |