Commit ·
22374d1
0
Parent(s):
Add heaptrm package: v2 harness, CLI, pwntools integration, CVE tests
Browse files- heaptrm/harness/heapgrid_v2.c: Enhanced harness with metadata corruption
detection (catches heap overflows, double-frees). Zero false positives on
5 CVE-pattern tests.
- heaptrm/cli.py: Standalone CLI (heaptrm scan/analyze/watch)
- heaptrm/integrations/pwntools.py: HeapWatch for pwntools processes
- heaptrm/monitor.py: HeapMonitor API combining corruption + ML detection
- cve_tests/: 5 realistic CVE pattern simulations (4/5 detected)
- docker/: Cross-glibc testing (2.27/2.31/2.35/2.39, 90-97% detection)
- 30 heap exploitation techniques, 304K param TRM classifier
- .gitattributes +35 -0
- .gitignore +35 -0
- README.md +156 -0
- agent/enhanced_grid.py +255 -0
- agent/multi_technique.py +504 -0
- agent/policy.py +161 -0
- agent/real_binary_bridge.py +331 -0
- agent/search.py +365 -0
- agent/simple_agent.py +251 -0
- agent/train_enhanced.py +437 -0
- agent/train_on_real.py +304 -0
- agent/train_universal.py +334 -0
- agent/train_with_demos.py +291 -0
- agent/universal_grid.py +263 -0
- ctf/drive_ctf.py +291 -0
- ctf/run_ctf_validation.py +276 -0
- ctf/vuln_heap.c +117 -0
- cve_tests/README.md +18 -0
- cve_tests/cve_sims.c +264 -0
- cve_tests/run_cve_tests.py +235 -0
- dataset/dataset_gen.py +247 -0
- docker/cross_glibc.sh +102 -0
- docker/run_glibc_tests.sh +119 -0
- harness/Makefile +13 -0
- harness/heapgrid_harness.c +328 -0
- heaptrm/__init__.py +18 -0
- heaptrm/classifier/__init__.py +0 -0
- heaptrm/classifier/grid.py +85 -0
- heaptrm/classifier/model.py +73 -0
- heaptrm/cli.py +160 -0
- heaptrm/harness/heapgrid_v2.c +390 -0
- heaptrm/integrations/__init__.py +0 -0
- heaptrm/integrations/pwntools.py +251 -0
- heaptrm/monitor.py +229 -0
- model/trm_heap.py +358 -0
- runner/augment_data.py +109 -0
- runner/gen_padded.py +164 -0
- runner/run_poc.py +212 -0
- simulator/heap_sim.py +570 -0
- viz/README.md +29 -0
- viz/generate_trajectory.py +201 -0
- viz/index.html +886 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Large directories excluded from HF upload
|
| 2 |
+
ctf-repo/
|
| 3 |
+
how2heap/
|
| 4 |
+
data/dumps/
|
| 5 |
+
data/bins/
|
| 6 |
+
data/bins_augmented/
|
| 7 |
+
data/processed_aug/
|
| 8 |
+
data/dumps_glibc/
|
| 9 |
+
pickle_deser/
|
| 10 |
+
.venv/
|
| 11 |
+
|
| 12 |
+
# Binary artifacts
|
| 13 |
+
*.pt
|
| 14 |
+
*.npy
|
| 15 |
+
*.npz
|
| 16 |
+
*.so
|
| 17 |
+
ctf/vuln_heap
|
| 18 |
+
ctf/dumps/
|
| 19 |
+
ctf/scripts/
|
| 20 |
+
ctf/processed/
|
| 21 |
+
|
| 22 |
+
# Python
|
| 23 |
+
__pycache__/
|
| 24 |
+
*.pyc
|
| 25 |
+
*.pyo
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.eggs/
|
| 28 |
+
|
| 29 |
+
# OS
|
| 30 |
+
.DS_Store
|
| 31 |
+
Thumbs.db
|
| 32 |
+
|
| 33 |
+
# IDE
|
| 34 |
+
.vscode/
|
| 35 |
+
.idea/
|
README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- security
|
| 5 |
+
- heap-exploitation
|
| 6 |
+
- deserialization
|
| 7 |
+
- tiny-recursive-model
|
| 8 |
+
- ctf
|
| 9 |
+
- exploit-detection
|
| 10 |
+
- action-prediction
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# HeapTRM: Tiny Recursive Models for Security Primitives
|
| 14 |
+
|
| 15 |
+
Applying the Tiny Recursive Model architecture ([arXiv 2510.04871](https://arxiv.org/abs/2510.04871)) to security — heap exploit detection, deserialization attack classification, and guided exploit generation on real binaries.
|
| 16 |
+
|
| 17 |
+
A 304K-parameter model (~0.3MB) that reasons about exploit-relevant structure through recursive processing of grid-encoded state.
|
| 18 |
+
|
| 19 |
+
## Results Summary
|
| 20 |
+
|
| 21 |
+
### Classification (proven, strong)
|
| 22 |
+
|
| 23 |
+
| Task | F1 | Precision | Recall | Test Set |
|
| 24 |
+
|---|---|---|---|---|
|
| 25 |
+
| Heap exploit detection (script-level) | **0.958** | 97.1% | 94.4% | CTF binary, noisy exploits vs benign |
|
| 26 |
+
| Heap exploit detection (state-level) | 0.842 | 84.2% | 84.2% | Per-operation heap state classification |
|
| 27 |
+
| Pickle deserialization detection | **0.818** | 73.1% | 92.9% | Held-out attack families (shutil, socket, BUILD) |
|
| 28 |
+
|
| 29 |
+
### Action Prediction (experimental)
|
| 30 |
+
|
| 31 |
+
| Task | Result | Notes |
|
| 32 |
+
|---|---|---|
|
| 33 |
+
| Simulator (tcache poison) | 100% best-of-10 | Positional memorization; does not transfer |
|
| 34 |
+
| Real binary (single technique) | **74% greedy, 100% best-of-10** | Hybrid: TRM for malloc/free, rule for UAF write |
|
| 35 |
+
| Real binary (multi-technique) | 42% tcache + 18% off-by-one | TRM selects technique, rules trigger writes |
|
| 36 |
+
| Ablation: write_UAF with chunks | **98% accuracy** | vs 74% without chunk data — structure matters for trigger timing |
|
| 37 |
+
|
| 38 |
+
### Ablation Study
|
| 39 |
+
|
| 40 |
+
| Grid Variant | Val Acc | Write_UAF Acc | Interpretation |
|
| 41 |
+
|---|---|---|---|
|
| 42 |
+
| Full grid (chunks + history + summary) | 0.864 | 0.98 | Best overall |
|
| 43 |
+
| No chunks (counters only) | 0.833 | 0.74 | Counters sufficient for M/F phase |
|
| 44 |
+
| Chunks only (no history) | 0.842 | 0.73 | Structure alone is comparable |
|
| 45 |
+
| History only | 0.807 | 0.49 | Weakest — needs state context |
|
| 46 |
+
|
| 47 |
+
**Key finding:** Chunk structure adds +24 percentage points specifically on the exploit-critical write trigger decision, even though overall accuracy gains only +3%. The model uses spatial heap reasoning where it matters most.
|
| 48 |
+
|
| 49 |
+
## Architecture
|
| 50 |
+
|
| 51 |
+
```
|
| 52 |
+
Input: 32x16 integer grid (vocab_size=64)
|
| 53 |
+
Rows 0-23: Chunk metadata (state, size, adjacency, fd/bk, coalesce potential)
|
| 54 |
+
Rows 24-27: Action history (last 4 operations)
|
| 55 |
+
Rows 28-31: Heap summary statistics
|
| 56 |
+
|
| 57 |
+
Model: TRM (2 recursive blocks, 6 inner iterations)
|
| 58 |
+
Token embedding (64 -> 128 dim) + positional embedding
|
| 59 |
+
Recursive: z = z + block_z(x + y + z); y = y + block_y(y + z)
|
| 60 |
+
Output: mean pool -> linear head
|
| 61 |
+
|
| 62 |
+
Parameters: 304,260 (~0.3MB)
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
## Components
|
| 66 |
+
|
| 67 |
+
### Heap Instrumentation (`harness/`)
|
| 68 |
+
- `heapgrid_harness.c` — LD_PRELOAD library that hooks malloc/free/calloc/realloc
|
| 69 |
+
- Dumps heap chunk metadata (size, flags, fd/bk, state) as JSONL after every operation
|
| 70 |
+
- Works with any dynamically-linked binary on Linux
|
| 71 |
+
|
| 72 |
+
### Classifiers (`model/`, `dataset/`)
|
| 73 |
+
- `trm_heap.py` — TRM model with deep supervision, focal loss, training/eval loops
|
| 74 |
+
- `dataset_gen.py` — Converts harness JSONL dumps to 32x16 grid arrays
|
| 75 |
+
- Validated on 23 how2heap techniques across glibc 2.35-2.39
|
| 76 |
+
|
| 77 |
+
### Pickle Deserialization (`pickle_deser/`)
|
| 78 |
+
- `dumper.py` — Instrumented pickle unpickler using sentinels (safe, no code execution)
|
| 79 |
+
- `grid_encoder.py` — Encodes pickle VM stack/memo state as 32x16 grids
|
| 80 |
+
- `gen_payloads.py` — Generates benign + malicious + noisy pickle payloads
|
| 81 |
+
- Trained on os.system/subprocess/eval/exec, generalizes to shutil/socket/BUILD
|
| 82 |
+
|
| 83 |
+
### CTF Challenge (`ctf/`)
|
| 84 |
+
- `vuln_heap.c` — Menu-driven heap challenge with UAF + off-by-one bugs
|
| 85 |
+
- `drive_ctf.py` — Generates exploit + benign interaction scripts
|
| 86 |
+
- `run_ctf_validation.py` — End-to-end: instrument, collect, train, evaluate per-script
|
| 87 |
+
|
| 88 |
+
### Action Agent (`agent/`)
|
| 89 |
+
- `universal_grid.py` — Allocator-agnostic grid encoding (relationships, not internals)
|
| 90 |
+
- `simple_agent.py` — TRM policy for operation type prediction (4-class)
|
| 91 |
+
- `train_enhanced.py` — Training on real binary dumps with history tracking
|
| 92 |
+
- `multi_technique.py` — Multi-technique agent (tcache poison + off-by-one + coalesce)
|
| 93 |
+
- `train_universal.py` — GPU training with universal grid
|
| 94 |
+
|
| 95 |
+
### Heap Simulator (`simulator/`)
|
| 96 |
+
- `heap_sim.py` — Lightweight ptmalloc2 simulator (tcache, fastbins, coalescing, top chunk)
|
| 97 |
+
- Used for self-play experiments; sim-to-real transfer validated
|
| 98 |
+
|
| 99 |
+
## Quickstart
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
# Setup
|
| 103 |
+
uv venv .venv --python 3.12
|
| 104 |
+
uv pip install torch --index-url https://download.pytorch.org/whl/cu124 -p .venv
|
| 105 |
+
uv pip install numpy -p .venv
|
| 106 |
+
|
| 107 |
+
# Build heap harness
|
| 108 |
+
make -C harness/
|
| 109 |
+
|
| 110 |
+
# Run heap classifier validation (how2heap + CTF)
|
| 111 |
+
python3 runner/run_poc.py
|
| 112 |
+
|
| 113 |
+
# Run pickle deserialization classifier
|
| 114 |
+
python3 pickle_deser/run_poc.py
|
| 115 |
+
|
| 116 |
+
# Run CTF exploit detection
|
| 117 |
+
python3 ctf/run_ctf_validation.py
|
| 118 |
+
|
| 119 |
+
# Train action agent on real binary (GPU)
|
| 120 |
+
.venv/bin/python3 agent/train_universal.py
|
| 121 |
+
|
| 122 |
+
# Run multi-technique agent
|
| 123 |
+
.venv/bin/python3 agent/multi_technique.py
|
| 124 |
+
|
| 125 |
+
# Run ablation study
|
| 126 |
+
.venv/bin/python3 agent/ablation.py # or inline script from experiments
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
## Honest Assessment
|
| 130 |
+
|
| 131 |
+
**What works:** Classification. TRM learns exploit-relevant structure from grid-encoded state and generalizes across technique families. Zero false positives on CTF detection. The LD_PRELOAD harness is a useful standalone tool.
|
| 132 |
+
|
| 133 |
+
**What partially works:** Action prediction with hybrid architecture (TRM for phase, rules for trigger). Achieves 74% single-technique on real binaries but relies on hand-crafted rules for the critical write step.
|
| 134 |
+
|
| 135 |
+
**What doesn't work:** Pure end-to-end action prediction from grid to operation. The 128-action space overwhelms the model. Simulator-trained policies don't transfer to real binaries without matching grid encoders. The model takes counter shortcuts over spatial reasoning for M/F decisions.
|
| 136 |
+
|
| 137 |
+
**Key insight:** TRM's recursive processing genuinely helps for exploit trigger timing (98% vs 74% per ablation) but not for phase sequencing (counters suffice). The architecture is best suited as a classifier/oracle rather than a standalone agent.
|
| 138 |
+
|
| 139 |
+
## Checkpoints
|
| 140 |
+
|
| 141 |
+
- `data/checkpoints_focal/best_model.pt` — Heap classifier (focal loss, best F1=0.636 on held-out techniques)
|
| 142 |
+
- `ctf/checkpoints/best_model.pt` — CTF exploit detector (F1=0.842 state-level, F1=0.958 script-level)
|
| 143 |
+
- `pickle_deser/checkpoints/best_model.pt` — Pickle deser classifier (F1=0.818)
|
| 144 |
+
- `agent/checkpoints/` — Action prediction models
|
| 145 |
+
|
| 146 |
+
## Citation
|
| 147 |
+
|
| 148 |
+
Based on:
|
| 149 |
+
```
|
| 150 |
+
@article{jolicoeur2025less,
|
| 151 |
+
title={Less is More: Recursive Reasoning with Tiny Networks},
|
| 152 |
+
author={Jolicoeur-Martineau, Alexia},
|
| 153 |
+
journal={arXiv preprint arXiv:2510.04871},
|
| 154 |
+
year={2025}
|
| 155 |
+
}
|
| 156 |
+
```
|
agent/enhanced_grid.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
enhanced_grid.py - Improved grid encoding for action prediction.
|
| 3 |
+
|
| 4 |
+
Key changes from the original:
|
| 5 |
+
1. Encodes structurally meaningful features (tcache occupancy, freed counts)
|
| 6 |
+
2. Adds action history buffer (last 4 ops encoded into dedicated grid rows)
|
| 7 |
+
3. Adds summary rows with aggregate heap statistics
|
| 8 |
+
|
| 9 |
+
Grid layout (32 rows x 16 cols):
|
| 10 |
+
Rows 0-23: Chunk data (same as before but with better features)
|
| 11 |
+
Rows 24-27: Action history (last 4 actions, newest first)
|
| 12 |
+
Rows 28-31: Heap summary statistics
|
| 13 |
+
|
| 14 |
+
This gives TRM both the current state AND recent trajectory context.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import json
|
| 19 |
+
from typing import List, Dict, Optional
|
| 20 |
+
from collections import deque
|
| 21 |
+
|
| 22 |
+
GRID_ROWS = 32
|
| 23 |
+
GRID_COLS = 16
|
| 24 |
+
CHUNK_ROWS = 24
|
| 25 |
+
HISTORY_ROWS = 4
|
| 26 |
+
SUMMARY_ROWS = 4
|
| 27 |
+
VOCAB_SIZE = 64
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def clamp(val, lo=0, hi=63):
|
| 31 |
+
return max(lo, min(hi, int(val)))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Op encoding for history rows
|
| 35 |
+
OP_ENC = {"malloc": 1, "calloc": 2, "free": 3, "realloc": 4, "edit": 5}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class GridEncoder:
|
| 39 |
+
"""Stateful grid encoder that tracks action history."""
|
| 40 |
+
|
| 41 |
+
def __init__(self):
|
| 42 |
+
self.action_history = deque(maxlen=HISTORY_ROWS)
|
| 43 |
+
self.total_allocs = 0
|
| 44 |
+
self.total_frees = 0
|
| 45 |
+
self.total_writes = 0
|
| 46 |
+
self.step = 0
|
| 47 |
+
|
| 48 |
+
def record_action(self, op_type: int, size: int = 0):
|
| 49 |
+
"""Record an action taken (0=malloc, 1=free, 2=write_freed)."""
|
| 50 |
+
self.action_history.appendleft({
|
| 51 |
+
"op": op_type,
|
| 52 |
+
"size": size,
|
| 53 |
+
"step": self.step,
|
| 54 |
+
})
|
| 55 |
+
if op_type == 0:
|
| 56 |
+
self.total_allocs += 1
|
| 57 |
+
elif op_type == 1:
|
| 58 |
+
self.total_frees += 1
|
| 59 |
+
elif op_type == 2:
|
| 60 |
+
self.total_writes += 1
|
| 61 |
+
self.step += 1
|
| 62 |
+
|
| 63 |
+
def encode(self, state: dict) -> np.ndarray:
|
| 64 |
+
"""Encode a heap dump state + history into a 32x16 grid."""
|
| 65 |
+
grid = np.zeros((GRID_ROWS, GRID_COLS), dtype=np.int64)
|
| 66 |
+
chunks = state.get("chunks", [])
|
| 67 |
+
|
| 68 |
+
# --- Compute aggregate stats ---
|
| 69 |
+
n_allocated = sum(1 for c in chunks if c.get("state") == 1)
|
| 70 |
+
n_freed = sum(1 for c in chunks if c.get("state") == 2)
|
| 71 |
+
n_total = len(chunks)
|
| 72 |
+
|
| 73 |
+
# Group chunks by size class
|
| 74 |
+
size_classes = {}
|
| 75 |
+
for c in chunks:
|
| 76 |
+
sc = c.get("chunk_size", 0) >> 4
|
| 77 |
+
if sc not in size_classes:
|
| 78 |
+
size_classes[sc] = {"alloc": 0, "freed": 0}
|
| 79 |
+
if c.get("state") == 1:
|
| 80 |
+
size_classes[sc]["alloc"] += 1
|
| 81 |
+
elif c.get("state") == 2:
|
| 82 |
+
size_classes[sc]["freed"] += 1
|
| 83 |
+
|
| 84 |
+
# Find dominant size class (most chunks)
|
| 85 |
+
dominant_sc = 0
|
| 86 |
+
if size_classes:
|
| 87 |
+
dominant_sc = max(size_classes, key=lambda k:
|
| 88 |
+
size_classes[k]["alloc"] + size_classes[k]["freed"])
|
| 89 |
+
|
| 90 |
+
# Count freed chunks with non-zero fd (already poisoned)
|
| 91 |
+
n_poisoned = sum(1 for c in chunks
|
| 92 |
+
if c.get("state") == 2 and c.get("fd", 0) != 0)
|
| 93 |
+
|
| 94 |
+
# --- Rows 0-23: Chunk data ---
|
| 95 |
+
for i, c in enumerate(chunks[:CHUNK_ROWS]):
|
| 96 |
+
# Col 0: state (1=alloc, 2=freed)
|
| 97 |
+
grid[i, 0] = clamp(c.get("state", 0), 0, 2)
|
| 98 |
+
|
| 99 |
+
# Col 1: size class
|
| 100 |
+
grid[i, 1] = clamp(c.get("chunk_size", 0) >> 4)
|
| 101 |
+
|
| 102 |
+
# Col 2: prev_inuse flag
|
| 103 |
+
grid[i, 2] = c.get("flag_p", 0)
|
| 104 |
+
|
| 105 |
+
# Col 3: is this chunk's size the dominant size class?
|
| 106 |
+
cs = c.get("chunk_size", 0) >> 4
|
| 107 |
+
grid[i, 3] = 1 if cs == dominant_sc else 0
|
| 108 |
+
|
| 109 |
+
# Col 4: fd resolves to another chunk index
|
| 110 |
+
fd_idx = c.get("fd_idx", -1)
|
| 111 |
+
if fd_idx == -1:
|
| 112 |
+
grid[i, 4] = 0
|
| 113 |
+
elif fd_idx == -2:
|
| 114 |
+
grid[i, 4] = 33 # external
|
| 115 |
+
else:
|
| 116 |
+
grid[i, 4] = clamp(fd_idx + 1, 1, 32)
|
| 117 |
+
|
| 118 |
+
# Col 5: bk resolves to chunk index
|
| 119 |
+
bk_idx = c.get("bk_idx", -1)
|
| 120 |
+
if bk_idx == -1:
|
| 121 |
+
grid[i, 5] = 0
|
| 122 |
+
elif bk_idx == -2:
|
| 123 |
+
grid[i, 5] = 33
|
| 124 |
+
else:
|
| 125 |
+
grid[i, 5] = clamp(bk_idx + 1, 1, 32)
|
| 126 |
+
|
| 127 |
+
# Col 6: how many same-size chunks are allocated
|
| 128 |
+
same_alloc = size_classes.get(cs, {}).get("alloc", 0)
|
| 129 |
+
grid[i, 6] = clamp(same_alloc)
|
| 130 |
+
|
| 131 |
+
# Col 7: how many same-size chunks are freed
|
| 132 |
+
same_freed = size_classes.get(cs, {}).get("freed", 0)
|
| 133 |
+
grid[i, 7] = clamp(same_freed)
|
| 134 |
+
|
| 135 |
+
# Col 8: is this chunk the target of the last operation
|
| 136 |
+
grid[i, 8] = c.get("is_target", 0)
|
| 137 |
+
|
| 138 |
+
# Col 9: is double freed
|
| 139 |
+
grid[i, 9] = c.get("is_double_freed", 0)
|
| 140 |
+
|
| 141 |
+
# Col 10: alloc order
|
| 142 |
+
grid[i, 10] = clamp(c.get("alloc_order", 0))
|
| 143 |
+
|
| 144 |
+
# Col 11: free order
|
| 145 |
+
grid[i, 11] = clamp(c.get("free_order", 0))
|
| 146 |
+
|
| 147 |
+
# Col 12: has fd been modified (non-standard for freed chunk)
|
| 148 |
+
if c.get("state") == 2 and c.get("fd", 0) != 0:
|
| 149 |
+
grid[i, 12] = 1 # fd is set on freed chunk
|
| 150 |
+
else:
|
| 151 |
+
grid[i, 12] = 0
|
| 152 |
+
|
| 153 |
+
# Col 13: chunk index position
|
| 154 |
+
grid[i, 13] = clamp(i)
|
| 155 |
+
|
| 156 |
+
# Col 14: request size class
|
| 157 |
+
grid[i, 14] = clamp(c.get("req_size", 0) >> 3)
|
| 158 |
+
|
| 159 |
+
# Col 15: n_total chunks in heap (global context per row)
|
| 160 |
+
grid[i, 15] = clamp(n_total)
|
| 161 |
+
|
| 162 |
+
# --- Rows 24-27: Action history (last 4 ops) ---
|
| 163 |
+
for i, action in enumerate(self.action_history):
|
| 164 |
+
row = CHUNK_ROWS + i
|
| 165 |
+
if row >= CHUNK_ROWS + HISTORY_ROWS:
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
# Col 0: marker (this is a history row)
|
| 169 |
+
grid[row, 0] = 60 # special marker
|
| 170 |
+
|
| 171 |
+
# Col 1: op type (0=malloc, 1=free, 2=write)
|
| 172 |
+
grid[row, 1] = clamp(action["op"] + 1, 1, 5)
|
| 173 |
+
|
| 174 |
+
# Col 2: size of the action
|
| 175 |
+
grid[row, 2] = clamp(action.get("size", 0) >> 4)
|
| 176 |
+
|
| 177 |
+
# Col 3: how many steps ago
|
| 178 |
+
grid[row, 3] = clamp(self.step - action["step"])
|
| 179 |
+
|
| 180 |
+
# Col 4: total allocs at time of action
|
| 181 |
+
grid[row, 4] = clamp(self.total_allocs)
|
| 182 |
+
|
| 183 |
+
# Col 5: total frees at time of action
|
| 184 |
+
grid[row, 5] = clamp(self.total_frees)
|
| 185 |
+
|
| 186 |
+
# --- Rows 28-31: Heap summary statistics ---
|
| 187 |
+
summary_row = CHUNK_ROWS + HISTORY_ROWS
|
| 188 |
+
|
| 189 |
+
# Row 28: counts
|
| 190 |
+
grid[summary_row, 0] = 50 # marker
|
| 191 |
+
grid[summary_row, 1] = clamp(n_allocated)
|
| 192 |
+
grid[summary_row, 2] = clamp(n_freed)
|
| 193 |
+
grid[summary_row, 3] = clamp(n_total)
|
| 194 |
+
grid[summary_row, 4] = clamp(self.total_allocs)
|
| 195 |
+
grid[summary_row, 5] = clamp(self.total_frees)
|
| 196 |
+
grid[summary_row, 6] = clamp(self.total_writes)
|
| 197 |
+
grid[summary_row, 7] = clamp(self.step)
|
| 198 |
+
grid[summary_row, 8] = clamp(n_poisoned)
|
| 199 |
+
# Col 9: phase indicator (derived from counts)
|
| 200 |
+
if n_freed == 0:
|
| 201 |
+
phase = 1 # allocation phase
|
| 202 |
+
elif self.total_writes == 0 and n_freed > 0:
|
| 203 |
+
phase = 2 # ready to corrupt
|
| 204 |
+
elif self.total_writes > 0:
|
| 205 |
+
phase = 3 # corruption done, drain phase
|
| 206 |
+
else:
|
| 207 |
+
phase = 0
|
| 208 |
+
grid[summary_row, 9] = phase
|
| 209 |
+
# Col 10: ratio allocated/total (quantized)
|
| 210 |
+
if n_total > 0:
|
| 211 |
+
grid[summary_row, 10] = clamp(int(n_allocated / n_total * 63))
|
| 212 |
+
# Col 11: dominant size class
|
| 213 |
+
grid[summary_row, 11] = clamp(dominant_sc)
|
| 214 |
+
# Col 12: number of distinct size classes
|
| 215 |
+
grid[summary_row, 12] = clamp(len(size_classes))
|
| 216 |
+
|
| 217 |
+
# Row 29: per-size-class tcache info (top 4 size classes)
|
| 218 |
+
for j, (sc, info) in enumerate(sorted(size_classes.items())[:4]):
|
| 219 |
+
r = summary_row + 1 + j
|
| 220 |
+
if r >= GRID_ROWS:
|
| 221 |
+
break
|
| 222 |
+
grid[r, 0] = 51 # marker
|
| 223 |
+
grid[r, 1] = clamp(sc)
|
| 224 |
+
grid[r, 2] = clamp(info["alloc"])
|
| 225 |
+
grid[r, 3] = clamp(info["freed"])
|
| 226 |
+
grid[r, 4] = clamp(info["alloc"] + info["freed"])
|
| 227 |
+
|
| 228 |
+
return grid
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def encode_from_dump_with_history(
|
| 232 |
+
states: list,
|
| 233 |
+
op_labels: list,
|
| 234 |
+
) -> tuple:
|
| 235 |
+
"""Encode a sequence of dump states with action history tracking.
|
| 236 |
+
|
| 237 |
+
Returns (grids, labels) where each grid includes history context.
|
| 238 |
+
"""
|
| 239 |
+
encoder = GridEncoder()
|
| 240 |
+
grids = []
|
| 241 |
+
labels = []
|
| 242 |
+
|
| 243 |
+
for i, (state, label) in enumerate(zip(states, op_labels)):
|
| 244 |
+
grid = encoder.encode(state)
|
| 245 |
+
grids.append(grid)
|
| 246 |
+
labels.append(label)
|
| 247 |
+
|
| 248 |
+
# Record this action for next step's history
|
| 249 |
+
size = 0
|
| 250 |
+
target = state.get("target_size", 0)
|
| 251 |
+
if target:
|
| 252 |
+
size = target
|
| 253 |
+
encoder.record_action(label, size)
|
| 254 |
+
|
| 255 |
+
return np.stack(grids), np.array(labels, dtype=np.int64)
|
agent/multi_technique.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
multi_technique.py - Train one model on multiple exploit techniques.
|
| 4 |
+
|
| 5 |
+
Techniques:
|
| 6 |
+
1. Tcache poison (UAF fd corruption): M+ F F W_UAF M+
|
| 7 |
+
2. Off-by-one overlap (null byte overflow): M M M W_OVF F M
|
| 8 |
+
3. Coalesce abuse (free adjacent, alloc large): M M M F F M_large
|
| 9 |
+
|
| 10 |
+
Action space (4 ops):
|
| 11 |
+
0: MALLOC
|
| 12 |
+
1: FREE
|
| 13 |
+
2: WRITE_UAF (edit freed chunk - corrupt fd pointer)
|
| 14 |
+
3: WRITE_OVERFLOW (edit allocated chunk with max data - triggers off-by-one)
|
| 15 |
+
|
| 16 |
+
The model must learn WHICH technique to apply based on heap state.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
import os
|
| 21 |
+
import json
|
| 22 |
+
import subprocess
|
| 23 |
+
import tempfile
|
| 24 |
+
import time
|
| 25 |
+
import random
|
| 26 |
+
import re
|
| 27 |
+
import numpy as np
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn.functional as F
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 33 |
+
sys.path.insert(0, str(ROOT / "agent"))
|
| 34 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 35 |
+
|
| 36 |
+
from universal_grid import UniversalGridEncoder
|
| 37 |
+
from trm_heap import RMSNorm, SwiGLU, RecursionBlock
|
| 38 |
+
|
| 39 |
+
BINARY = ROOT / "ctf" / "vuln_heap"
|
| 40 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 41 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 42 |
+
|
| 43 |
+
OP_MALLOC = 0
|
| 44 |
+
OP_FREE = 1
|
| 45 |
+
OP_WRITE_UAF = 2
|
| 46 |
+
OP_WRITE_OVF = 3
|
| 47 |
+
N_OPS = 4
|
| 48 |
+
SIZES = [0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x78, 0x80]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class MultiTechTRM(torch.nn.Module):
|
| 52 |
+
"""TRM for 4-class technique-aware op prediction."""
|
| 53 |
+
def __init__(self, vocab_size=64, hidden_dim=128, seq_len=512,
|
| 54 |
+
n_outer=2, n_inner=3):
|
| 55 |
+
super().__init__()
|
| 56 |
+
self.embed = torch.nn.Embedding(vocab_size, hidden_dim)
|
| 57 |
+
self.y_init = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 58 |
+
self.z_init = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 59 |
+
self.block_z = RecursionBlock(hidden_dim)
|
| 60 |
+
self.block_y = RecursionBlock(hidden_dim)
|
| 61 |
+
self.pos_embed = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 62 |
+
self.out_norm = RMSNorm(hidden_dim)
|
| 63 |
+
self.n_outer = n_outer
|
| 64 |
+
self.n_inner = n_inner
|
| 65 |
+
self.head = torch.nn.Linear(hidden_dim, N_OPS)
|
| 66 |
+
|
| 67 |
+
def forward(self, x):
|
| 68 |
+
B = x.shape[0]
|
| 69 |
+
h = self.embed(x.reshape(B, -1)) + self.pos_embed
|
| 70 |
+
y = self.y_init.expand(B, -1, -1)
|
| 71 |
+
z = self.z_init.expand(B, -1, -1)
|
| 72 |
+
for _ in range(self.n_outer):
|
| 73 |
+
for _ in range(self.n_inner):
|
| 74 |
+
z = z + self.block_z(h + y + z)
|
| 75 |
+
y = y + self.block_y(y + z)
|
| 76 |
+
pooled = self.out_norm(y).mean(dim=1)
|
| 77 |
+
return self.head(pooled)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_and_dump(commands):
|
| 81 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 82 |
+
env = os.environ.copy()
|
| 83 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 84 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 85 |
+
subprocess.run([str(BINARY)], input=("\n".join(commands) + "\n5\n").encode(),
|
| 86 |
+
env=env, capture_output=True, timeout=10)
|
| 87 |
+
states = []
|
| 88 |
+
if os.path.exists(dump_path):
|
| 89 |
+
with open(dump_path) as f:
|
| 90 |
+
for line in f:
|
| 91 |
+
if line.strip():
|
| 92 |
+
states.append(json.loads(line.strip()))
|
| 93 |
+
os.unlink(dump_path)
|
| 94 |
+
return states
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ============================================================
|
| 98 |
+
# TECHNIQUE 1: Tcache poison (UAF fd write)
|
| 99 |
+
# Pattern: M+ F F W_UAF M+
|
| 100 |
+
# ============================================================
|
| 101 |
+
|
| 102 |
+
def gen_tcache_poison(n_pre=None):
|
| 103 |
+
size = random.choice([s for s in SIZES if s <= 0x70]) # tcache sizes
|
| 104 |
+
slots = random.sample(range(16), 8)
|
| 105 |
+
if n_pre is None:
|
| 106 |
+
n_pre = random.randint(2, 5)
|
| 107 |
+
|
| 108 |
+
commands, labels = [], []
|
| 109 |
+
alloc_slots = []
|
| 110 |
+
for i in range(n_pre):
|
| 111 |
+
commands.append(f"1 {slots[i]} {size}")
|
| 112 |
+
labels.append(OP_MALLOC)
|
| 113 |
+
alloc_slots.append(slots[i])
|
| 114 |
+
|
| 115 |
+
targets = random.sample(alloc_slots, 2)
|
| 116 |
+
for s in targets:
|
| 117 |
+
commands.append(f"4 {s}")
|
| 118 |
+
labels.append(OP_FREE)
|
| 119 |
+
|
| 120 |
+
# UAF write: edit freed chunk
|
| 121 |
+
commands.append(f"2 {targets[1]} {'41' * 8}")
|
| 122 |
+
labels.append(OP_WRITE_UAF)
|
| 123 |
+
|
| 124 |
+
drain = [s for s in slots if s not in alloc_slots][:2]
|
| 125 |
+
for s in drain:
|
| 126 |
+
commands.append(f"1 {s} {size}")
|
| 127 |
+
labels.append(OP_MALLOC)
|
| 128 |
+
|
| 129 |
+
return commands, labels, "tcache_poison"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ============================================================
|
| 133 |
+
# TECHNIQUE 2: Off-by-one null byte overlap
|
| 134 |
+
# Pattern: M M M W_OVF F M
|
| 135 |
+
# Alloc 3 adjacent chunks, overflow A into B, free B (corrupted size), alloc over it
|
| 136 |
+
# ============================================================
|
| 137 |
+
|
| 138 |
+
def gen_off_by_one():
|
| 139 |
+
size = 0x78 # max size before rounding — null byte overflows into next chunk
|
| 140 |
+
slots = random.sample(range(16), 6)
|
| 141 |
+
|
| 142 |
+
commands, labels = [], []
|
| 143 |
+
|
| 144 |
+
# Alloc 3 adjacent: A, B, C
|
| 145 |
+
for i in range(3):
|
| 146 |
+
commands.append(f"1 {slots[i]} {size}")
|
| 147 |
+
labels.append(OP_MALLOC)
|
| 148 |
+
|
| 149 |
+
# Overflow A: write full 0x78 bytes, null byte hits B's prev_inuse
|
| 150 |
+
commands.append(f"2 {slots[0]} {'41' * size}")
|
| 151 |
+
labels.append(OP_WRITE_OVF)
|
| 152 |
+
|
| 153 |
+
# Free B (with corrupted size)
|
| 154 |
+
commands.append(f"4 {slots[1]}")
|
| 155 |
+
labels.append(OP_FREE)
|
| 156 |
+
|
| 157 |
+
# Alloc over corrupted region
|
| 158 |
+
commands.append(f"1 {slots[3]} {size}")
|
| 159 |
+
labels.append(OP_MALLOC)
|
| 160 |
+
|
| 161 |
+
return commands, labels, "off_by_one"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ============================================================
|
| 165 |
+
# TECHNIQUE 3: Coalesce confusion
|
| 166 |
+
# Pattern: M M M F F M(larger) — free two adjacent, alloc large to get overlap
|
| 167 |
+
# ============================================================
|
| 168 |
+
|
| 169 |
+
def gen_coalesce():
|
| 170 |
+
small = random.choice([0x30, 0x40, 0x50])
|
| 171 |
+
slots = random.sample(range(16), 7)
|
| 172 |
+
|
| 173 |
+
commands, labels = [], []
|
| 174 |
+
n_pre = random.randint(3, 5)
|
| 175 |
+
|
| 176 |
+
for i in range(n_pre):
|
| 177 |
+
commands.append(f"1 {slots[i]} {small}")
|
| 178 |
+
labels.append(OP_MALLOC)
|
| 179 |
+
|
| 180 |
+
# Free two adjacent chunks (indices 1 and 2 should be adjacent)
|
| 181 |
+
commands.append(f"4 {slots[1]}")
|
| 182 |
+
labels.append(OP_FREE)
|
| 183 |
+
commands.append(f"4 {slots[2]}")
|
| 184 |
+
labels.append(OP_FREE)
|
| 185 |
+
|
| 186 |
+
# Alloc larger chunk that overlaps the coalesced region
|
| 187 |
+
big = small * 2 + 0x10
|
| 188 |
+
if big > 0x80:
|
| 189 |
+
big = 0x80
|
| 190 |
+
commands.append(f"1 {slots[n_pre]} {big}")
|
| 191 |
+
labels.append(OP_MALLOC)
|
| 192 |
+
|
| 193 |
+
return commands, labels, "coalesce"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ============================================================
|
| 197 |
+
# BENIGN sequences
|
| 198 |
+
# ============================================================
|
| 199 |
+
|
| 200 |
+
def gen_benign():
|
| 201 |
+
n_ops = random.randint(4, 12)
|
| 202 |
+
commands, labels = [], []
|
| 203 |
+
allocated = {}
|
| 204 |
+
for _ in range(n_ops):
|
| 205 |
+
if not allocated or random.random() < 0.55:
|
| 206 |
+
free_s = [s for s in range(16) if s not in allocated]
|
| 207 |
+
if not free_s:
|
| 208 |
+
break
|
| 209 |
+
s = random.choice(free_s)
|
| 210 |
+
sz = random.choice(SIZES)
|
| 211 |
+
commands.append(f"1 {s} {sz}")
|
| 212 |
+
labels.append(OP_MALLOC)
|
| 213 |
+
allocated[s] = sz
|
| 214 |
+
else:
|
| 215 |
+
s = random.choice(list(allocated.keys()))
|
| 216 |
+
commands.append(f"4 {s}")
|
| 217 |
+
labels.append(OP_FREE)
|
| 218 |
+
del allocated[s]
|
| 219 |
+
return commands, labels, "benign"
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ============================================================
|
| 223 |
+
# DATA COLLECTION
|
| 224 |
+
# ============================================================
|
| 225 |
+
|
| 226 |
+
def collect_data(n_per_technique=200, n_benign=300):
|
| 227 |
+
all_grids, all_labels = [], []
|
| 228 |
+
technique_counts = {}
|
| 229 |
+
|
| 230 |
+
generators = [
|
| 231 |
+
("tcache_poison", gen_tcache_poison, n_per_technique),
|
| 232 |
+
("off_by_one", gen_off_by_one, n_per_technique),
|
| 233 |
+
("coalesce", gen_coalesce, n_per_technique),
|
| 234 |
+
("benign", gen_benign, n_benign),
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
for tech_name, gen_fn, count in generators:
|
| 238 |
+
print(f" Generating {count} {tech_name}...")
|
| 239 |
+
n_ok = 0
|
| 240 |
+
for _ in range(count):
|
| 241 |
+
if tech_name == "tcache_poison":
|
| 242 |
+
commands, op_labels, _ = gen_fn()
|
| 243 |
+
else:
|
| 244 |
+
commands, op_labels, _ = gen_fn()
|
| 245 |
+
|
| 246 |
+
states = run_and_dump(commands)
|
| 247 |
+
if len(states) < 2:
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
encoder = UniversalGridEncoder()
|
| 251 |
+
|
| 252 |
+
# Phase-based labeling (what should happen NEXT)
|
| 253 |
+
n_free_seen = 0
|
| 254 |
+
n_ovf_seen = 0
|
| 255 |
+
write_done = False
|
| 256 |
+
|
| 257 |
+
for j, state in enumerate(states):
|
| 258 |
+
op = state.get("operation", "malloc")
|
| 259 |
+
if "free" in op.lower():
|
| 260 |
+
n_free_seen += 1
|
| 261 |
+
|
| 262 |
+
chunks = state.get("chunks", [])
|
| 263 |
+
n_alloc = sum(1 for c in chunks if c.get("state") == 1)
|
| 264 |
+
n_freed = sum(1 for c in chunks if c.get("state") == 2)
|
| 265 |
+
|
| 266 |
+
# Determine next label based on technique
|
| 267 |
+
if tech_name == "tcache_poison":
|
| 268 |
+
if n_free_seen == 0:
|
| 269 |
+
label = OP_FREE if (n_alloc >= 2 and j >= len(states) * 0.3) else OP_MALLOC
|
| 270 |
+
elif n_free_seen >= 2 and not write_done:
|
| 271 |
+
label = OP_WRITE_UAF
|
| 272 |
+
write_done = True
|
| 273 |
+
else:
|
| 274 |
+
label = OP_MALLOC
|
| 275 |
+
|
| 276 |
+
elif tech_name == "off_by_one":
|
| 277 |
+
if n_alloc >= 3 and n_free_seen == 0 and not write_done:
|
| 278 |
+
label = OP_WRITE_OVF
|
| 279 |
+
write_done = True
|
| 280 |
+
elif write_done and n_free_seen == 0:
|
| 281 |
+
label = OP_FREE
|
| 282 |
+
elif n_free_seen >= 1 and write_done:
|
| 283 |
+
label = OP_MALLOC
|
| 284 |
+
else:
|
| 285 |
+
label = OP_MALLOC
|
| 286 |
+
|
| 287 |
+
elif tech_name == "coalesce":
|
| 288 |
+
if n_free_seen == 0 and n_alloc >= 3:
|
| 289 |
+
label = OP_FREE
|
| 290 |
+
elif n_free_seen == 1:
|
| 291 |
+
label = OP_FREE # free second adjacent
|
| 292 |
+
elif n_free_seen >= 2:
|
| 293 |
+
label = OP_MALLOC # alloc large
|
| 294 |
+
else:
|
| 295 |
+
label = OP_MALLOC
|
| 296 |
+
|
| 297 |
+
else: # benign
|
| 298 |
+
if j + 1 < len(states):
|
| 299 |
+
next_op = states[j+1].get("operation", "malloc")
|
| 300 |
+
label = OP_FREE if "free" in next_op.lower() else OP_MALLOC
|
| 301 |
+
else:
|
| 302 |
+
label = OP_MALLOC
|
| 303 |
+
|
| 304 |
+
grid = encoder.encode(state)
|
| 305 |
+
all_grids.append(grid)
|
| 306 |
+
all_labels.append(label)
|
| 307 |
+
|
| 308 |
+
actual_op = OP_FREE if "free" in op.lower() else OP_MALLOC
|
| 309 |
+
encoder.record_action(actual_op, state.get("target_size", 0))
|
| 310 |
+
|
| 311 |
+
n_ok += 1
|
| 312 |
+
|
| 313 |
+
technique_counts[tech_name] = n_ok
|
| 314 |
+
|
| 315 |
+
print(f" Technique counts: {technique_counts}")
|
| 316 |
+
return np.stack(all_grids), np.array(all_labels, dtype=np.int64)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def train_gpu(model, X, y, epochs=300, lr=1e-3, bs=128):
|
| 320 |
+
model.to(DEVICE)
|
| 321 |
+
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 322 |
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
|
| 323 |
+
X_t = torch.from_numpy(X).long().to(DEVICE)
|
| 324 |
+
y_t = torch.from_numpy(y).long().to(DEVICE)
|
| 325 |
+
n = len(X_t)
|
| 326 |
+
|
| 327 |
+
for ep in range(1, epochs + 1):
|
| 328 |
+
model.train()
|
| 329 |
+
perm = torch.randperm(n, device=DEVICE)
|
| 330 |
+
total_loss = correct = nb = 0
|
| 331 |
+
for i in range(0, n, bs):
|
| 332 |
+
idx = perm[i:i+bs]
|
| 333 |
+
logits = model(X_t[idx])
|
| 334 |
+
loss = F.cross_entropy(logits, y_t[idx])
|
| 335 |
+
opt.zero_grad()
|
| 336 |
+
loss.backward()
|
| 337 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 338 |
+
opt.step()
|
| 339 |
+
total_loss += loss.item()
|
| 340 |
+
correct += (logits.argmax(1) == y_t[idx]).sum().item()
|
| 341 |
+
nb += 1
|
| 342 |
+
sched.step()
|
| 343 |
+
if ep % 25 == 0 or ep == 1:
|
| 344 |
+
print(f" Epoch {ep:3d} | loss={total_loss/nb:.4f} | acc={correct/n:.3f}")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def evaluate_live(model, n_trials=50, temperature=0.3):
|
| 348 |
+
"""Evaluate with hybrid policy: TRM picks op, rules handle W timing."""
|
| 349 |
+
model.eval().to(DEVICE)
|
| 350 |
+
results = {"uaf": 0, "ovf": 0, "correct_seq": 0, "seqs": []}
|
| 351 |
+
|
| 352 |
+
for trial in range(n_trials):
|
| 353 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 354 |
+
env = os.environ.copy()
|
| 355 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 356 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 357 |
+
proc = subprocess.Popen([str(BINARY)], stdin=subprocess.PIPE,
|
| 358 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
| 359 |
+
|
| 360 |
+
encoder = UniversalGridEncoder()
|
| 361 |
+
slots, freed, ops = {}, set(), []
|
| 362 |
+
did_uaf = did_ovf = False
|
| 363 |
+
n_consec_frees = 0
|
| 364 |
+
|
| 365 |
+
for step in range(20):
|
| 366 |
+
time.sleep(0.01)
|
| 367 |
+
state = {"chunks": []}
|
| 368 |
+
try:
|
| 369 |
+
with open(dump_path) as f:
|
| 370 |
+
lines = f.readlines()
|
| 371 |
+
if lines:
|
| 372 |
+
state = json.loads(lines[-1].strip())
|
| 373 |
+
except:
|
| 374 |
+
pass
|
| 375 |
+
|
| 376 |
+
grid = encoder.encode(state)
|
| 377 |
+
x = torch.from_numpy(grid).long().unsqueeze(0).to(DEVICE)
|
| 378 |
+
with torch.no_grad():
|
| 379 |
+
logits = model(x)
|
| 380 |
+
probs = F.softmax(logits / temperature, dim=1)
|
| 381 |
+
op = torch.multinomial(probs, 1).item()
|
| 382 |
+
|
| 383 |
+
# Rule overrides for W timing
|
| 384 |
+
if op == OP_WRITE_UAF and not freed:
|
| 385 |
+
op = OP_MALLOC # can't UAF without freed chunks
|
| 386 |
+
if op == OP_WRITE_OVF and not any(v for v in slots.values()):
|
| 387 |
+
op = OP_MALLOC # need allocated chunks
|
| 388 |
+
|
| 389 |
+
free_s = [s for s in range(8) if s not in slots]
|
| 390 |
+
alloc_s = [s for s, v in slots.items() if v and s not in freed]
|
| 391 |
+
|
| 392 |
+
cmd = None
|
| 393 |
+
size = 0
|
| 394 |
+
if op == OP_MALLOC and free_s:
|
| 395 |
+
s = random.choice(free_s)
|
| 396 |
+
size = random.choice(SIZES)
|
| 397 |
+
cmd = f"1 {s} {size}"
|
| 398 |
+
slots[s] = True
|
| 399 |
+
ops.append("M")
|
| 400 |
+
n_consec_frees = 0
|
| 401 |
+
elif op == OP_FREE and alloc_s:
|
| 402 |
+
s = random.choice(alloc_s)
|
| 403 |
+
cmd = f"4 {s}"
|
| 404 |
+
slots[s] = False
|
| 405 |
+
freed.add(s)
|
| 406 |
+
ops.append("F")
|
| 407 |
+
n_consec_frees += 1
|
| 408 |
+
elif op == OP_WRITE_UAF and freed:
|
| 409 |
+
s = random.choice(list(freed))
|
| 410 |
+
cmd = f"2 {s} {'41' * 8}"
|
| 411 |
+
did_uaf = True
|
| 412 |
+
ops.append("U")
|
| 413 |
+
elif op == OP_WRITE_OVF and alloc_s:
|
| 414 |
+
s = random.choice(alloc_s)
|
| 415 |
+
sz = slots.get(s) or 0x78
|
| 416 |
+
cmd = f"2 {s} {'41' * 0x78}" # max write triggers off-by-one
|
| 417 |
+
did_ovf = True
|
| 418 |
+
ops.append("O")
|
| 419 |
+
else:
|
| 420 |
+
ops.append("x")
|
| 421 |
+
|
| 422 |
+
if cmd:
|
| 423 |
+
proc.stdin.write((cmd + "\n").encode())
|
| 424 |
+
proc.stdin.flush()
|
| 425 |
+
encoder.record_action(op, size)
|
| 426 |
+
|
| 427 |
+
try:
|
| 428 |
+
proc.stdin.write(b"5\n"); proc.stdin.flush()
|
| 429 |
+
proc.wait(timeout=2)
|
| 430 |
+
except:
|
| 431 |
+
proc.kill()
|
| 432 |
+
|
| 433 |
+
# Check results
|
| 434 |
+
if did_uaf:
|
| 435 |
+
try:
|
| 436 |
+
with open(dump_path) as f:
|
| 437 |
+
for line in f:
|
| 438 |
+
st = json.loads(line.strip())
|
| 439 |
+
for c in st.get("chunks", []):
|
| 440 |
+
if c.get("fd", 0) == 0x4141414141414141:
|
| 441 |
+
results["uaf"] += 1
|
| 442 |
+
break
|
| 443 |
+
else:
|
| 444 |
+
continue
|
| 445 |
+
break
|
| 446 |
+
except:
|
| 447 |
+
pass
|
| 448 |
+
|
| 449 |
+
if did_ovf:
|
| 450 |
+
results["ovf"] += 1
|
| 451 |
+
|
| 452 |
+
seq = "".join(ops)
|
| 453 |
+
# Valid patterns: M+F+U (tcache poison), M+O+F+M (off-by-one), M+FF+M (coalesce)
|
| 454 |
+
if re.search(r"M+F+U", seq.replace("x", "")) or \
|
| 455 |
+
re.search(r"M+OF+M", seq.replace("x", "")) or \
|
| 456 |
+
re.search(r"M+FF+M", seq.replace("x", "")):
|
| 457 |
+
results["correct_seq"] += 1
|
| 458 |
+
|
| 459 |
+
results["seqs"].append(seq)
|
| 460 |
+
if os.path.exists(dump_path):
|
| 461 |
+
os.unlink(dump_path)
|
| 462 |
+
|
| 463 |
+
return results
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def main():
|
| 467 |
+
print(f"Device: {DEVICE}")
|
| 468 |
+
|
| 469 |
+
print("\n=== Collecting multi-technique data ===")
|
| 470 |
+
X, y = collect_data(n_per_technique=300, n_benign=300)
|
| 471 |
+
print(f"Data: {len(X)} samples | M={sum(y==0)} F={sum(y==1)} U={sum(y==2)} O={sum(y==3)}")
|
| 472 |
+
|
| 473 |
+
print("\n=== Training on GPU ===")
|
| 474 |
+
model = MultiTechTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 475 |
+
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
|
| 476 |
+
train_gpu(model, X, y, epochs=300, lr=1e-3, bs=128)
|
| 477 |
+
|
| 478 |
+
print("\n=== Live evaluation (50 trials) ===")
|
| 479 |
+
results = evaluate_live(model, n_trials=50, temperature=0.3)
|
| 480 |
+
print(f"UAF writes achieved: {results['uaf']}/50 ({results['uaf']*2}%)")
|
| 481 |
+
print(f"Overflow writes: {results['ovf']}/50 ({results['ovf']*2}%)")
|
| 482 |
+
print(f"Valid exploit sequences: {results['correct_seq']}/50 ({results['correct_seq']*2}%)")
|
| 483 |
+
print("\nOp distribution in sequences:")
|
| 484 |
+
all_ops = "".join(results["seqs"])
|
| 485 |
+
for ch, name in [("M","malloc"), ("F","free"), ("U","uaf_write"), ("O","overflow"), ("x","skip")]:
|
| 486 |
+
print(f" {name}: {all_ops.count(ch)}")
|
| 487 |
+
print("\nSample sequences:")
|
| 488 |
+
for s in results["seqs"][:15]:
|
| 489 |
+
print(f" {s}")
|
| 490 |
+
|
| 491 |
+
# Per-technique analysis
|
| 492 |
+
print("\nPer-technique breakdown:")
|
| 493 |
+
has_uaf = sum(1 for s in results["seqs"] if "U" in s)
|
| 494 |
+
has_ovf = sum(1 for s in results["seqs"] if "O" in s)
|
| 495 |
+
has_both = sum(1 for s in results["seqs"] if "U" in s and "O" in s)
|
| 496 |
+
neither = sum(1 for s in results["seqs"] if "U" not in s and "O" not in s)
|
| 497 |
+
print(f" Used tcache poison (U): {has_uaf}/50")
|
| 498 |
+
print(f" Used off-by-one (O): {has_ovf}/50")
|
| 499 |
+
print(f" Used both: {has_both}/50")
|
| 500 |
+
print(f" Used neither: {neither}/50")
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
if __name__ == "__main__":
|
| 504 |
+
main()
|
agent/policy.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
policy.py - TRM as a policy/value network for heap exploitation.
|
| 3 |
+
|
| 4 |
+
The policy network takes a heap state grid and outputs:
|
| 5 |
+
1. Action scores for each possible action
|
| 6 |
+
2. Value estimate (how close to achieving the goal primitive)
|
| 7 |
+
|
| 8 |
+
Action space (simplified for PoC):
|
| 9 |
+
- malloc(size, slot): size in {0x20, 0x30, ..., 0x80}, slot in {0..7}
|
| 10 |
+
- free(slot): slot in {0..7}
|
| 11 |
+
- write_freed(slot, target_slot): UAF write fd to another chunk's addr
|
| 12 |
+
|
| 13 |
+
Total actions: 7 sizes * 8 slots + 8 frees + 8*8 writes = 56 + 8 + 64 = 128
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
import numpy as np
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "model"))
|
| 24 |
+
from trm_heap import RMSNorm, SwiGLU, RecursionBlock
|
| 25 |
+
|
| 26 |
+
# Action space
|
| 27 |
+
MALLOC_SIZES = [0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80]
|
| 28 |
+
NUM_SLOTS = 8
|
| 29 |
+
N_MALLOC_ACTIONS = len(MALLOC_SIZES) * NUM_SLOTS # 56
|
| 30 |
+
N_FREE_ACTIONS = NUM_SLOTS # 8
|
| 31 |
+
N_WRITE_ACTIONS = NUM_SLOTS * NUM_SLOTS # 64
|
| 32 |
+
TOTAL_ACTIONS = N_MALLOC_ACTIONS + N_FREE_ACTIONS + N_WRITE_ACTIONS # 128
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def decode_action(action_idx: int) -> dict:
|
| 36 |
+
"""Decode action index to operation dict."""
|
| 37 |
+
if action_idx < N_MALLOC_ACTIONS:
|
| 38 |
+
size_idx = action_idx // NUM_SLOTS
|
| 39 |
+
slot = action_idx % NUM_SLOTS
|
| 40 |
+
return {"op": "malloc", "size": MALLOC_SIZES[size_idx], "slot": slot}
|
| 41 |
+
|
| 42 |
+
action_idx -= N_MALLOC_ACTIONS
|
| 43 |
+
if action_idx < N_FREE_ACTIONS:
|
| 44 |
+
return {"op": "free", "slot": action_idx}
|
| 45 |
+
|
| 46 |
+
action_idx -= N_FREE_ACTIONS
|
| 47 |
+
src_slot = action_idx // NUM_SLOTS
|
| 48 |
+
tgt_slot = action_idx % NUM_SLOTS
|
| 49 |
+
return {"op": "write_freed", "slot": src_slot, "target_slot": tgt_slot}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def encode_action(op: str, **kwargs) -> int:
|
| 53 |
+
"""Encode operation dict to action index."""
|
| 54 |
+
if op == "malloc":
|
| 55 |
+
size_idx = MALLOC_SIZES.index(kwargs["size"])
|
| 56 |
+
return size_idx * NUM_SLOTS + kwargs["slot"]
|
| 57 |
+
elif op == "free":
|
| 58 |
+
return N_MALLOC_ACTIONS + kwargs["slot"]
|
| 59 |
+
elif op == "write_freed":
|
| 60 |
+
return (N_MALLOC_ACTIONS + N_FREE_ACTIONS +
|
| 61 |
+
kwargs["slot"] * NUM_SLOTS + kwargs["target_slot"])
|
| 62 |
+
raise ValueError(f"Unknown op: {op}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class HeapPolicyTRM(nn.Module):
|
| 66 |
+
"""TRM-based policy + value network for heap exploitation."""
|
| 67 |
+
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
vocab_size: int = 64,
|
| 71 |
+
hidden_dim: int = 128,
|
| 72 |
+
seq_len: int = 512, # 32 * 16
|
| 73 |
+
n_outer: int = 2,
|
| 74 |
+
n_inner: int = 3,
|
| 75 |
+
n_actions: int = TOTAL_ACTIONS,
|
| 76 |
+
):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.seq_len = seq_len
|
| 79 |
+
self.n_outer = n_outer
|
| 80 |
+
self.n_inner = n_inner
|
| 81 |
+
|
| 82 |
+
self.embed = nn.Embedding(vocab_size, hidden_dim)
|
| 83 |
+
self.y_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 84 |
+
self.z_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 85 |
+
self.block_z = RecursionBlock(hidden_dim)
|
| 86 |
+
self.block_y = RecursionBlock(hidden_dim)
|
| 87 |
+
self.pos_embed = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 88 |
+
|
| 89 |
+
self.out_norm = RMSNorm(hidden_dim)
|
| 90 |
+
|
| 91 |
+
# Policy head: action probabilities
|
| 92 |
+
self.policy_head = nn.Sequential(
|
| 93 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 94 |
+
nn.SiLU(),
|
| 95 |
+
nn.Linear(hidden_dim, n_actions),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Value head: how close to exploit primitive (0-1)
|
| 99 |
+
self.value_head = nn.Sequential(
|
| 100 |
+
nn.Linear(hidden_dim, hidden_dim // 2),
|
| 101 |
+
nn.SiLU(),
|
| 102 |
+
nn.Linear(hidden_dim // 2, 1),
|
| 103 |
+
nn.Sigmoid(),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
def forward(self, x: torch.Tensor) -> tuple:
|
| 107 |
+
"""
|
| 108 |
+
Args:
|
| 109 |
+
x: (batch, 32, 16) integer grid
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
policy_logits: (batch, n_actions)
|
| 113 |
+
value: (batch, 1)
|
| 114 |
+
"""
|
| 115 |
+
B = x.shape[0]
|
| 116 |
+
x_flat = x.reshape(B, -1)
|
| 117 |
+
h = self.embed(x_flat) + self.pos_embed
|
| 118 |
+
|
| 119 |
+
y = self.y_init.expand(B, -1, -1)
|
| 120 |
+
z = self.z_init.expand(B, -1, -1)
|
| 121 |
+
|
| 122 |
+
for t in range(self.n_outer):
|
| 123 |
+
for i in range(self.n_inner):
|
| 124 |
+
z = z + self.block_z(h + y + z)
|
| 125 |
+
y = y + self.block_y(y + z)
|
| 126 |
+
|
| 127 |
+
pooled = self.out_norm(y).mean(dim=1)
|
| 128 |
+
|
| 129 |
+
policy_logits = self.policy_head(pooled)
|
| 130 |
+
value = self.value_head(pooled)
|
| 131 |
+
|
| 132 |
+
return policy_logits, value
|
| 133 |
+
|
| 134 |
+
def get_action(self, grid: np.ndarray, valid_mask: np.ndarray = None,
|
| 135 |
+
temperature: float = 1.0) -> tuple:
|
| 136 |
+
"""Get action from policy for a single state.
|
| 137 |
+
|
| 138 |
+
Returns: (action_idx, action_dict, log_prob, value)
|
| 139 |
+
"""
|
| 140 |
+
self.eval()
|
| 141 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 142 |
+
|
| 143 |
+
with torch.no_grad():
|
| 144 |
+
logits, value = self(x)
|
| 145 |
+
logits = logits.squeeze(0)
|
| 146 |
+
value = value.squeeze().item()
|
| 147 |
+
|
| 148 |
+
if valid_mask is not None:
|
| 149 |
+
# Mask invalid actions
|
| 150 |
+
logits[~torch.from_numpy(valid_mask).bool()] = -1e9
|
| 151 |
+
|
| 152 |
+
if temperature == 0:
|
| 153 |
+
action_idx = logits.argmax().item()
|
| 154 |
+
log_prob = 0.0
|
| 155 |
+
else:
|
| 156 |
+
probs = F.softmax(logits / temperature, dim=0)
|
| 157 |
+
action_idx = torch.multinomial(probs, 1).item()
|
| 158 |
+
log_prob = torch.log(probs[action_idx]).item()
|
| 159 |
+
|
| 160 |
+
action_dict = decode_action(action_idx)
|
| 161 |
+
return action_idx, action_dict, log_prob, value
|
agent/real_binary_bridge.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
real_binary_bridge.py - Connect TRM agent to a real binary via LD_PRELOAD harness.
|
| 4 |
+
|
| 5 |
+
Architecture:
|
| 6 |
+
1. Launch vuln_heap binary with heapgrid_harness.so (LD_PRELOAD)
|
| 7 |
+
2. Agent reads heap state from harness dump file after each command
|
| 8 |
+
3. Agent predicts next operation type via TRM
|
| 9 |
+
4. Translates to menu commands and sends via stdin pipe
|
| 10 |
+
5. Repeats until exploit primitive detected or max steps
|
| 11 |
+
|
| 12 |
+
This is the simulator-to-reality transfer test.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import subprocess
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import json
|
| 19 |
+
import time
|
| 20 |
+
import random
|
| 21 |
+
import tempfile
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 28 |
+
sys.path.insert(0, str(ROOT / "agent"))
|
| 29 |
+
sys.path.insert(0, str(ROOT / "simulator"))
|
| 30 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 31 |
+
sys.path.insert(0, str(ROOT / "dataset"))
|
| 32 |
+
|
| 33 |
+
from simple_agent import SimpleHeapTRM, OP_MALLOC, OP_FREE, OP_WRITE_FREED, SIZES
|
| 34 |
+
from dataset_gen import state_to_grid, load_dump
|
| 35 |
+
|
| 36 |
+
BINARY = ROOT / "ctf" / "vuln_heap"
|
| 37 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class RealBinaryEnv:
|
| 41 |
+
"""Drives a real binary with the heap harness, providing grid observations."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, binary=BINARY, harness=HARNESS):
|
| 44 |
+
self.binary = str(binary)
|
| 45 |
+
self.harness = str(harness)
|
| 46 |
+
self.proc = None
|
| 47 |
+
self.dump_file = None
|
| 48 |
+
self.slots = {} # slot -> allocated (True/False)
|
| 49 |
+
self.slot_sizes = {} # slot -> size
|
| 50 |
+
self.commands_sent = []
|
| 51 |
+
self._last_dump_lines = 0
|
| 52 |
+
|
| 53 |
+
def start(self):
|
| 54 |
+
"""Launch the binary with harness."""
|
| 55 |
+
self.dump_file = tempfile.NamedTemporaryFile(
|
| 56 |
+
suffix=".jsonl", delete=False, mode="w"
|
| 57 |
+
)
|
| 58 |
+
self.dump_path = self.dump_file.name
|
| 59 |
+
self.dump_file.close()
|
| 60 |
+
|
| 61 |
+
env = os.environ.copy()
|
| 62 |
+
env["LD_PRELOAD"] = self.harness
|
| 63 |
+
env["HEAPGRID_OUT"] = self.dump_path
|
| 64 |
+
|
| 65 |
+
self.proc = subprocess.Popen(
|
| 66 |
+
[self.binary],
|
| 67 |
+
stdin=subprocess.PIPE,
|
| 68 |
+
stdout=subprocess.PIPE,
|
| 69 |
+
stderr=subprocess.PIPE,
|
| 70 |
+
env=env,
|
| 71 |
+
)
|
| 72 |
+
self.slots = {}
|
| 73 |
+
self.slot_sizes = {}
|
| 74 |
+
self.commands_sent = []
|
| 75 |
+
self._last_dump_lines = 0
|
| 76 |
+
|
| 77 |
+
def send_command(self, cmd: str):
|
| 78 |
+
"""Send a menu command to the binary."""
|
| 79 |
+
self.proc.stdin.write((cmd + "\n").encode())
|
| 80 |
+
self.proc.stdin.flush()
|
| 81 |
+
self.commands_sent.append(cmd)
|
| 82 |
+
time.sleep(0.01) # let the binary process
|
| 83 |
+
|
| 84 |
+
def do_malloc(self, slot: int, size: int):
|
| 85 |
+
"""Menu option 1: allocate note."""
|
| 86 |
+
self.send_command(f"1 {slot} {size}")
|
| 87 |
+
self.slots[slot] = True
|
| 88 |
+
self.slot_sizes[slot] = size
|
| 89 |
+
|
| 90 |
+
def do_free(self, slot: int):
|
| 91 |
+
"""Menu option 4: delete note."""
|
| 92 |
+
self.send_command(f"4 {slot}")
|
| 93 |
+
self.slots[slot] = False # freed but pointer not cleared (UAF)
|
| 94 |
+
|
| 95 |
+
def do_edit_uaf(self, slot: int, data_hex: str):
|
| 96 |
+
"""Menu option 2: edit note (works on freed chunks = UAF)."""
|
| 97 |
+
self.send_command(f"2 {slot} {data_hex}")
|
| 98 |
+
|
| 99 |
+
def do_show(self, slot: int):
|
| 100 |
+
"""Menu option 3: show note."""
|
| 101 |
+
self.send_command(f"3 {slot}")
|
| 102 |
+
|
| 103 |
+
def do_exit(self):
|
| 104 |
+
"""Menu option 5: exit."""
|
| 105 |
+
self.send_command("5")
|
| 106 |
+
|
| 107 |
+
def get_heap_state(self) -> dict:
|
| 108 |
+
"""Read the latest heap state from the dump file."""
|
| 109 |
+
try:
|
| 110 |
+
with open(self.dump_path, "r") as f:
|
| 111 |
+
lines = f.readlines()
|
| 112 |
+
if not lines:
|
| 113 |
+
return None
|
| 114 |
+
# Parse the last line
|
| 115 |
+
last_line = lines[-1].strip()
|
| 116 |
+
if last_line:
|
| 117 |
+
return json.loads(last_line)
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
def get_grid(self) -> np.ndarray:
|
| 123 |
+
"""Get current heap state as a 32x16 grid."""
|
| 124 |
+
state = self.get_heap_state()
|
| 125 |
+
if state is None:
|
| 126 |
+
return np.zeros((32, 16), dtype=np.int64)
|
| 127 |
+
return state_to_grid(state)
|
| 128 |
+
|
| 129 |
+
def get_all_states(self) -> list:
|
| 130 |
+
"""Read all heap states from dump."""
|
| 131 |
+
try:
|
| 132 |
+
return load_dump(Path(self.dump_path))
|
| 133 |
+
except Exception:
|
| 134 |
+
return []
|
| 135 |
+
|
| 136 |
+
def check_duplicate_alloc(self) -> bool:
|
| 137 |
+
"""Check if any two slots point to the same address (from dump data)."""
|
| 138 |
+
state = self.get_heap_state()
|
| 139 |
+
if state is None:
|
| 140 |
+
return False
|
| 141 |
+
|
| 142 |
+
# Check if any two chunks have is_target or if show reveals same data
|
| 143 |
+
# More reliable: check if the last malloc returned an address
|
| 144 |
+
# that was already allocated to another slot
|
| 145 |
+
chunks = state.get("chunks", [])
|
| 146 |
+
addrs = []
|
| 147 |
+
for c in chunks:
|
| 148 |
+
if c.get("state") == 1: # allocated
|
| 149 |
+
addrs.append(c.get("addr"))
|
| 150 |
+
# In the dump, allocated chunks sharing addresses = overlap
|
| 151 |
+
return len(addrs) != len(set(addrs))
|
| 152 |
+
|
| 153 |
+
def stop(self):
|
| 154 |
+
"""Clean up."""
|
| 155 |
+
if self.proc:
|
| 156 |
+
try:
|
| 157 |
+
self.proc.stdin.close()
|
| 158 |
+
self.proc.wait(timeout=2)
|
| 159 |
+
except Exception:
|
| 160 |
+
self.proc.kill()
|
| 161 |
+
if self.dump_path and os.path.exists(self.dump_path):
|
| 162 |
+
os.unlink(self.dump_path)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def run_agent_on_real_binary(
|
| 166 |
+
model: SimpleHeapTRM,
|
| 167 |
+
max_steps: int = 20,
|
| 168 |
+
temperature: float = 0.3,
|
| 169 |
+
verbose: bool = True,
|
| 170 |
+
) -> dict:
|
| 171 |
+
"""
|
| 172 |
+
Run the TRM agent against the real vuln_heap binary.
|
| 173 |
+
|
| 174 |
+
Returns dict with success info and the command sequence used.
|
| 175 |
+
"""
|
| 176 |
+
env = RealBinaryEnv()
|
| 177 |
+
env.start()
|
| 178 |
+
|
| 179 |
+
# Give the binary a moment to initialize
|
| 180 |
+
time.sleep(0.05)
|
| 181 |
+
|
| 182 |
+
result = {
|
| 183 |
+
"achieved": False,
|
| 184 |
+
"steps": 0,
|
| 185 |
+
"commands": [],
|
| 186 |
+
"op_sequence": [],
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
model.eval()
|
| 190 |
+
|
| 191 |
+
for step in range(max_steps):
|
| 192 |
+
# Get heap state grid from real binary
|
| 193 |
+
grid = env.get_grid()
|
| 194 |
+
|
| 195 |
+
# TRM predicts operation type
|
| 196 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 197 |
+
with torch.no_grad():
|
| 198 |
+
logits = model(x)
|
| 199 |
+
if temperature == 0:
|
| 200 |
+
op = logits.argmax(1).item()
|
| 201 |
+
else:
|
| 202 |
+
probs = F.softmax(logits / temperature, dim=1)
|
| 203 |
+
op = torch.multinomial(probs, 1).item()
|
| 204 |
+
|
| 205 |
+
# Translate operation to real binary commands
|
| 206 |
+
success = False
|
| 207 |
+
|
| 208 |
+
if op == OP_MALLOC:
|
| 209 |
+
# Find a free slot
|
| 210 |
+
free_slots = [s for s in range(16) if s not in env.slots or not env.slots.get(s)]
|
| 211 |
+
if free_slots:
|
| 212 |
+
slot = random.choice(free_slots[:8])
|
| 213 |
+
size = random.choice(SIZES)
|
| 214 |
+
env.do_malloc(slot, size)
|
| 215 |
+
success = True
|
| 216 |
+
if verbose:
|
| 217 |
+
print(f" Step {step}: MALLOC slot={slot} size={hex(size)}")
|
| 218 |
+
|
| 219 |
+
elif op == OP_FREE:
|
| 220 |
+
# Find an allocated slot
|
| 221 |
+
alloc_slots = [s for s, v in env.slots.items() if v]
|
| 222 |
+
if alloc_slots:
|
| 223 |
+
slot = random.choice(alloc_slots)
|
| 224 |
+
env.do_free(slot)
|
| 225 |
+
success = True
|
| 226 |
+
if verbose:
|
| 227 |
+
print(f" Step {step}: FREE slot={slot}")
|
| 228 |
+
|
| 229 |
+
elif op == OP_WRITE_FREED:
|
| 230 |
+
# Find a freed slot (UAF) and a target
|
| 231 |
+
freed_slots = [s for s, v in env.slots.items() if not v]
|
| 232 |
+
occupied = list(env.slots.keys())
|
| 233 |
+
if freed_slots and len(occupied) >= 2:
|
| 234 |
+
src = random.choice(freed_slots)
|
| 235 |
+
targets = [s for s in occupied if s != src]
|
| 236 |
+
if targets:
|
| 237 |
+
tgt = random.choice(targets)
|
| 238 |
+
# Write target's expected address as hex
|
| 239 |
+
# We don't know exact address, but we can write recognizable pattern
|
| 240 |
+
# For the UAF, write 8 bytes that would be interpreted as fd pointer
|
| 241 |
+
env.do_edit_uaf(src, "41" * 8) # 0x4141414141414141
|
| 242 |
+
success = True
|
| 243 |
+
if verbose:
|
| 244 |
+
print(f" Step {step}: WRITE_FREED slot={src} (UAF edit)")
|
| 245 |
+
|
| 246 |
+
result["op_sequence"].append(["malloc", "free", "write_freed", "noop"][op])
|
| 247 |
+
result["commands"] = env.commands_sent.copy()
|
| 248 |
+
|
| 249 |
+
if not success:
|
| 250 |
+
if verbose:
|
| 251 |
+
print(f" Step {step}: {['MALLOC','FREE','WRITE_FREED','NOOP'][op]} - skipped (no valid target)")
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
+
time.sleep(0.02) # let harness write
|
| 255 |
+
|
| 256 |
+
# Check for exploit primitive
|
| 257 |
+
if env.check_duplicate_alloc():
|
| 258 |
+
result["achieved"] = True
|
| 259 |
+
result["steps"] = step + 1
|
| 260 |
+
if verbose:
|
| 261 |
+
print(f" ** EXPLOIT PRIMITIVE ACHIEVED at step {step + 1}! **")
|
| 262 |
+
break
|
| 263 |
+
|
| 264 |
+
env.do_exit()
|
| 265 |
+
env.stop()
|
| 266 |
+
|
| 267 |
+
if not result["achieved"]:
|
| 268 |
+
result["steps"] = max_steps
|
| 269 |
+
|
| 270 |
+
return result
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def main():
|
| 274 |
+
# Load trained model
|
| 275 |
+
print("=== Loading trained model ===")
|
| 276 |
+
model = SimpleHeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 277 |
+
|
| 278 |
+
# Train fresh (same as simple_agent.py)
|
| 279 |
+
from simple_agent import generate_demos, train
|
| 280 |
+
X, y = generate_demos(300)
|
| 281 |
+
print(f"Training on {len(X)} demo samples...")
|
| 282 |
+
train(model, X, y, epochs=100, lr=1e-3)
|
| 283 |
+
|
| 284 |
+
# Test on real binary
|
| 285 |
+
print("\n=== Testing on real vuln_heap binary ===")
|
| 286 |
+
|
| 287 |
+
n_trials = 50
|
| 288 |
+
n_achieved = 0
|
| 289 |
+
all_steps = []
|
| 290 |
+
|
| 291 |
+
for trial in range(n_trials):
|
| 292 |
+
print(f"\n--- Trial {trial + 1}/{n_trials} ---")
|
| 293 |
+
result = run_agent_on_real_binary(
|
| 294 |
+
model, max_steps=20, temperature=0.3, verbose=True)
|
| 295 |
+
|
| 296 |
+
if result["achieved"]:
|
| 297 |
+
n_achieved += 1
|
| 298 |
+
all_steps.append(result["steps"])
|
| 299 |
+
print(f" SUCCESS in {result['steps']} steps")
|
| 300 |
+
print(f" Op sequence: {' -> '.join(result['op_sequence'][:result['steps']])}")
|
| 301 |
+
else:
|
| 302 |
+
print(f" FAILED after {result['steps']} steps")
|
| 303 |
+
|
| 304 |
+
print(f"\n{'='*60}")
|
| 305 |
+
print(f"REAL BINARY RESULTS")
|
| 306 |
+
print(f"{'='*60}")
|
| 307 |
+
print(f"Success rate: {n_achieved}/{n_trials} ({n_achieved/n_trials*100:.0f}%)")
|
| 308 |
+
if all_steps:
|
| 309 |
+
print(f"Avg steps when successful: {np.mean(all_steps):.1f}")
|
| 310 |
+
print(f"Min steps: {min(all_steps)}, Max steps: {max(all_steps)}")
|
| 311 |
+
|
| 312 |
+
# Best-of-10
|
| 313 |
+
print(f"\n=== Best-of-10 evaluation (20 trials) ===")
|
| 314 |
+
n_bo10 = 0
|
| 315 |
+
for trial in range(20):
|
| 316 |
+
found = False
|
| 317 |
+
for attempt in range(10):
|
| 318 |
+
result = run_agent_on_real_binary(
|
| 319 |
+
model, max_steps=20, temperature=0.5, verbose=False)
|
| 320 |
+
if result["achieved"]:
|
| 321 |
+
found = True
|
| 322 |
+
break
|
| 323 |
+
if found:
|
| 324 |
+
n_bo10 += 1
|
| 325 |
+
print(f" Trial {trial+1}: {'FOUND' if found else 'MISS'}")
|
| 326 |
+
|
| 327 |
+
print(f"\nBest-of-10 success: {n_bo10}/20 ({n_bo10/20*100:.0f}%)")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
main()
|
agent/search.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
search.py - Beam search and self-play training for HeapPolicyTRM.
|
| 3 |
+
|
| 4 |
+
Uses the heap simulator + TRM policy to find exploit sequences.
|
| 5 |
+
Training via REINFORCE with self-play rollouts.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
import numpy as np
|
| 11 |
+
import copy
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import List, Optional
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "simulator"))
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 19 |
+
|
| 20 |
+
from heap_sim import HeapSimulator, request_to_chunk_size
|
| 21 |
+
from policy import (
|
| 22 |
+
HeapPolicyTRM, decode_action, encode_action,
|
| 23 |
+
MALLOC_SIZES, NUM_SLOTS, TOTAL_ACTIONS,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class SearchNode:
|
| 29 |
+
"""A node in the beam search tree."""
|
| 30 |
+
sim: HeapSimulator
|
| 31 |
+
actions: List[int]
|
| 32 |
+
log_prob_sum: float
|
| 33 |
+
value: float
|
| 34 |
+
primitives: dict
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_valid_actions(sim: HeapSimulator) -> np.ndarray:
|
| 38 |
+
"""Return a boolean mask of valid actions given the current state."""
|
| 39 |
+
mask = np.zeros(TOTAL_ACTIONS, dtype=bool)
|
| 40 |
+
|
| 41 |
+
# Valid mallocs: slot must be empty
|
| 42 |
+
used_slots = set(sim.slots.keys())
|
| 43 |
+
for size_idx, size in enumerate(MALLOC_SIZES):
|
| 44 |
+
for slot in range(NUM_SLOTS):
|
| 45 |
+
if slot not in used_slots:
|
| 46 |
+
action_idx = size_idx * NUM_SLOTS + slot
|
| 47 |
+
mask[action_idx] = True
|
| 48 |
+
|
| 49 |
+
# Valid frees: slot must have an allocated chunk
|
| 50 |
+
base = len(MALLOC_SIZES) * NUM_SLOTS
|
| 51 |
+
for slot in range(NUM_SLOTS):
|
| 52 |
+
if slot in sim.slots:
|
| 53 |
+
user_addr = sim.slots[slot]
|
| 54 |
+
chunk_addr = user_addr - 16
|
| 55 |
+
chunk = sim.chunks.get(chunk_addr)
|
| 56 |
+
if chunk and chunk.allocated:
|
| 57 |
+
mask[base + slot] = True
|
| 58 |
+
|
| 59 |
+
# Valid write_freed: src slot must be freed, target must exist
|
| 60 |
+
base2 = base + NUM_SLOTS
|
| 61 |
+
for src_slot in range(NUM_SLOTS):
|
| 62 |
+
if src_slot in sim.slots:
|
| 63 |
+
user_addr = sim.slots[src_slot]
|
| 64 |
+
chunk_addr = user_addr - 16
|
| 65 |
+
chunk = sim.chunks.get(chunk_addr)
|
| 66 |
+
if chunk and not chunk.allocated:
|
| 67 |
+
for tgt_slot in range(NUM_SLOTS):
|
| 68 |
+
if tgt_slot in sim.slots and tgt_slot != src_slot:
|
| 69 |
+
mask[base2 + src_slot * NUM_SLOTS + tgt_slot] = True
|
| 70 |
+
|
| 71 |
+
return mask
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def execute_action(sim: HeapSimulator, action_idx: int) -> bool:
|
| 75 |
+
"""Execute an action on the simulator. Returns success."""
|
| 76 |
+
action = decode_action(action_idx)
|
| 77 |
+
|
| 78 |
+
if action["op"] == "malloc":
|
| 79 |
+
result = sim.malloc(action["size"], slot=action["slot"])
|
| 80 |
+
return result is not None
|
| 81 |
+
|
| 82 |
+
elif action["op"] == "free":
|
| 83 |
+
slot = action["slot"]
|
| 84 |
+
if slot in sim.slots:
|
| 85 |
+
return sim.free(user_addr=sim.slots[slot], slot=slot)
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
elif action["op"] == "write_freed":
|
| 89 |
+
src_slot = action["slot"]
|
| 90 |
+
tgt_slot = action["target_slot"]
|
| 91 |
+
if src_slot in sim.slots and tgt_slot in sim.slots:
|
| 92 |
+
target_addr = sim.slots[tgt_slot]
|
| 93 |
+
return sim.write_to_freed(sim.slots[src_slot], target_addr)
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def beam_search(
|
| 100 |
+
model: HeapPolicyTRM,
|
| 101 |
+
initial_sim: HeapSimulator,
|
| 102 |
+
goal: str = "tcache_poison",
|
| 103 |
+
beam_width: int = 16,
|
| 104 |
+
max_steps: int = 30,
|
| 105 |
+
temperature: float = 0.5,
|
| 106 |
+
) -> Optional[SearchNode]:
|
| 107 |
+
"""
|
| 108 |
+
Beam search for an exploit sequence.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
model: policy network
|
| 112 |
+
initial_sim: starting heap state
|
| 113 |
+
goal: which primitive to achieve
|
| 114 |
+
beam_width: number of candidates to keep
|
| 115 |
+
max_steps: maximum sequence length
|
| 116 |
+
temperature: for action sampling (0 = greedy)
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Best SearchNode that achieves the goal, or best-effort
|
| 120 |
+
"""
|
| 121 |
+
model.eval()
|
| 122 |
+
|
| 123 |
+
initial_prims = initial_sim.check_primitives()
|
| 124 |
+
if initial_prims.get(goal, False):
|
| 125 |
+
return SearchNode(initial_sim, [], 0.0, 1.0, initial_prims)
|
| 126 |
+
|
| 127 |
+
# Initialize beam
|
| 128 |
+
beam = [SearchNode(
|
| 129 |
+
sim=copy.deepcopy(initial_sim),
|
| 130 |
+
actions=[],
|
| 131 |
+
log_prob_sum=0.0,
|
| 132 |
+
value=0.0,
|
| 133 |
+
primitives=initial_prims,
|
| 134 |
+
)]
|
| 135 |
+
|
| 136 |
+
best_found = None
|
| 137 |
+
|
| 138 |
+
for step in range(max_steps):
|
| 139 |
+
candidates = []
|
| 140 |
+
|
| 141 |
+
for node in beam:
|
| 142 |
+
grid = node.sim.state_to_grid()
|
| 143 |
+
valid_mask = get_valid_actions(node.sim)
|
| 144 |
+
|
| 145 |
+
if not valid_mask.any():
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
# Get policy from TRM
|
| 149 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 150 |
+
with torch.no_grad():
|
| 151 |
+
logits, value = model(x)
|
| 152 |
+
logits = logits.squeeze(0)
|
| 153 |
+
value = value.squeeze().item()
|
| 154 |
+
|
| 155 |
+
# Mask invalid
|
| 156 |
+
logits_np = logits.numpy()
|
| 157 |
+
logits_np[~valid_mask] = -1e9
|
| 158 |
+
|
| 159 |
+
# Get top-k actions
|
| 160 |
+
if temperature == 0:
|
| 161 |
+
top_k = min(beam_width, valid_mask.sum())
|
| 162 |
+
top_actions = np.argsort(logits_np)[-top_k:][::-1]
|
| 163 |
+
else:
|
| 164 |
+
probs = F.softmax(torch.tensor(logits_np) / temperature, dim=0).numpy()
|
| 165 |
+
probs = probs * valid_mask # re-zero invalids
|
| 166 |
+
prob_sum = probs.sum()
|
| 167 |
+
if prob_sum < 1e-10:
|
| 168 |
+
continue
|
| 169 |
+
probs /= prob_sum
|
| 170 |
+
n_samples = min(beam_width, max(1, int(valid_mask.sum())))
|
| 171 |
+
top_actions = np.random.choice(
|
| 172 |
+
len(probs), size=n_samples, replace=False, p=probs)
|
| 173 |
+
|
| 174 |
+
for action_idx in top_actions:
|
| 175 |
+
new_sim = copy.deepcopy(node.sim)
|
| 176 |
+
success = execute_action(new_sim, action_idx)
|
| 177 |
+
if not success:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
log_prob = float(F.log_softmax(
|
| 181 |
+
torch.tensor(logits_np), dim=0)[action_idx])
|
| 182 |
+
|
| 183 |
+
primitives = new_sim.check_primitives()
|
| 184 |
+
new_node = SearchNode(
|
| 185 |
+
sim=new_sim,
|
| 186 |
+
actions=node.actions + [action_idx],
|
| 187 |
+
log_prob_sum=node.log_prob_sum + log_prob,
|
| 188 |
+
value=value,
|
| 189 |
+
primitives=primitives,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Check if goal achieved
|
| 193 |
+
if primitives.get(goal, False):
|
| 194 |
+
if best_found is None or len(new_node.actions) < len(best_found.actions):
|
| 195 |
+
best_found = new_node
|
| 196 |
+
print(f" [FOUND] {goal} in {len(new_node.actions)} steps!")
|
| 197 |
+
|
| 198 |
+
candidates.append(new_node)
|
| 199 |
+
|
| 200 |
+
if not candidates:
|
| 201 |
+
break
|
| 202 |
+
|
| 203 |
+
# Select top beam_width candidates by (value + log_prob/steps)
|
| 204 |
+
candidates.sort(
|
| 205 |
+
key=lambda n: n.value + n.log_prob_sum / max(len(n.actions), 1),
|
| 206 |
+
reverse=True
|
| 207 |
+
)
|
| 208 |
+
beam = candidates[:beam_width]
|
| 209 |
+
|
| 210 |
+
if best_found:
|
| 211 |
+
break # Found a solution
|
| 212 |
+
|
| 213 |
+
return best_found or (beam[0] if beam else None)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ============================================================
|
| 217 |
+
# SELF-PLAY DATA GENERATION
|
| 218 |
+
# ============================================================
|
| 219 |
+
|
| 220 |
+
def generate_episode(
|
| 221 |
+
model: HeapPolicyTRM,
|
| 222 |
+
goal: str = "tcache_poison",
|
| 223 |
+
max_steps: int = 30,
|
| 224 |
+
temperature: float = 1.0,
|
| 225 |
+
) -> dict:
|
| 226 |
+
"""
|
| 227 |
+
Generate one self-play episode.
|
| 228 |
+
|
| 229 |
+
Returns dict with:
|
| 230 |
+
states: list of grids
|
| 231 |
+
actions: list of action indices
|
| 232 |
+
rewards: list of per-step rewards
|
| 233 |
+
achieved: bool
|
| 234 |
+
"""
|
| 235 |
+
sim = HeapSimulator()
|
| 236 |
+
states = []
|
| 237 |
+
actions = []
|
| 238 |
+
rewards = []
|
| 239 |
+
log_probs = []
|
| 240 |
+
|
| 241 |
+
for step in range(max_steps):
|
| 242 |
+
grid = sim.state_to_grid()
|
| 243 |
+
valid_mask = get_valid_actions(sim)
|
| 244 |
+
|
| 245 |
+
if not valid_mask.any():
|
| 246 |
+
break
|
| 247 |
+
|
| 248 |
+
action_idx, action_dict, log_prob, value = model.get_action(
|
| 249 |
+
grid, valid_mask, temperature=temperature)
|
| 250 |
+
|
| 251 |
+
states.append(grid)
|
| 252 |
+
actions.append(action_idx)
|
| 253 |
+
log_probs.append(log_prob)
|
| 254 |
+
|
| 255 |
+
success = execute_action(sim, action_idx)
|
| 256 |
+
if not success:
|
| 257 |
+
rewards.append(-0.1) # penalty for invalid action
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
primitives = sim.check_primitives()
|
| 261 |
+
|
| 262 |
+
if primitives.get(goal, False):
|
| 263 |
+
rewards.append(1.0) # big reward for achieving goal
|
| 264 |
+
return {
|
| 265 |
+
"states": states, "actions": actions,
|
| 266 |
+
"rewards": rewards, "log_probs": log_probs,
|
| 267 |
+
"achieved": True, "n_steps": step + 1,
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# Small reward shaping: partial credit for progress
|
| 271 |
+
reward = 0.0
|
| 272 |
+
if any(primitives.values()):
|
| 273 |
+
reward = 0.3 # partial credit for any primitive
|
| 274 |
+
rewards.append(reward)
|
| 275 |
+
|
| 276 |
+
return {
|
| 277 |
+
"states": states, "actions": actions,
|
| 278 |
+
"rewards": rewards, "log_probs": log_probs,
|
| 279 |
+
"achieved": False, "n_steps": len(states),
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def train_selfplay(
|
| 284 |
+
model: HeapPolicyTRM,
|
| 285 |
+
goal: str = "tcache_poison",
|
| 286 |
+
n_episodes: int = 1000,
|
| 287 |
+
max_steps: int = 30,
|
| 288 |
+
lr: float = 1e-4,
|
| 289 |
+
gamma: float = 0.99,
|
| 290 |
+
print_every: int = 50,
|
| 291 |
+
) -> dict:
|
| 292 |
+
"""
|
| 293 |
+
Train via REINFORCE with self-play.
|
| 294 |
+
|
| 295 |
+
Returns training stats.
|
| 296 |
+
"""
|
| 297 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 298 |
+
stats = {"episode_rewards": [], "achieved": [], "lengths": []}
|
| 299 |
+
|
| 300 |
+
n_achieved = 0
|
| 301 |
+
temperature = 1.0
|
| 302 |
+
|
| 303 |
+
for ep in range(n_episodes):
|
| 304 |
+
episode = generate_episode(model, goal, max_steps, temperature)
|
| 305 |
+
stats["achieved"].append(episode["achieved"])
|
| 306 |
+
stats["lengths"].append(episode["n_steps"])
|
| 307 |
+
|
| 308 |
+
if episode["achieved"]:
|
| 309 |
+
n_achieved += 1
|
| 310 |
+
|
| 311 |
+
# Compute discounted returns
|
| 312 |
+
returns = []
|
| 313 |
+
G = 0
|
| 314 |
+
for r in reversed(episode["rewards"]):
|
| 315 |
+
G = r + gamma * G
|
| 316 |
+
returns.insert(0, G)
|
| 317 |
+
returns = torch.tensor(returns, dtype=torch.float32)
|
| 318 |
+
|
| 319 |
+
if len(returns) > 1:
|
| 320 |
+
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
|
| 321 |
+
|
| 322 |
+
# REINFORCE update
|
| 323 |
+
model.train()
|
| 324 |
+
total_loss = 0.0
|
| 325 |
+
|
| 326 |
+
for state, action, log_prob, G_t in zip(
|
| 327 |
+
episode["states"], episode["actions"],
|
| 328 |
+
episode["log_probs"], returns
|
| 329 |
+
):
|
| 330 |
+
x = torch.from_numpy(state).long().unsqueeze(0)
|
| 331 |
+
logits, value = model(x)
|
| 332 |
+
|
| 333 |
+
# Policy gradient loss
|
| 334 |
+
action_log_prob = F.log_softmax(logits, dim=1)[0, action]
|
| 335 |
+
policy_loss = -action_log_prob * G_t
|
| 336 |
+
|
| 337 |
+
# Value loss
|
| 338 |
+
value_loss = F.mse_loss(value.squeeze(), G_t)
|
| 339 |
+
|
| 340 |
+
loss = policy_loss + 0.5 * value_loss
|
| 341 |
+
total_loss += loss
|
| 342 |
+
|
| 343 |
+
if len(episode["states"]) > 0:
|
| 344 |
+
avg_loss = total_loss / len(episode["states"])
|
| 345 |
+
optimizer.zero_grad()
|
| 346 |
+
avg_loss.backward()
|
| 347 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 348 |
+
optimizer.step()
|
| 349 |
+
|
| 350 |
+
stats["episode_rewards"].append(sum(episode["rewards"]))
|
| 351 |
+
|
| 352 |
+
# Anneal temperature
|
| 353 |
+
temperature = max(0.1, 1.0 - ep / n_episodes)
|
| 354 |
+
|
| 355 |
+
if (ep + 1) % print_every == 0:
|
| 356 |
+
recent_achieved = sum(stats["achieved"][-print_every:])
|
| 357 |
+
recent_reward = np.mean(stats["episode_rewards"][-print_every:])
|
| 358 |
+
recent_len = np.mean(stats["lengths"][-print_every:])
|
| 359 |
+
print(f"Episode {ep+1:4d} | achieved={recent_achieved}/{print_every} "
|
| 360 |
+
f"| avg_reward={recent_reward:.3f} "
|
| 361 |
+
f"| avg_len={recent_len:.1f} "
|
| 362 |
+
f"| total_achieved={n_achieved}/{ep+1} "
|
| 363 |
+
f"| temp={temperature:.2f}")
|
| 364 |
+
|
| 365 |
+
return stats
|
agent/simple_agent.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
simple_agent.py - Simplified action-prediction agent.
|
| 4 |
+
|
| 5 |
+
Instead of predicting exact (op, size, slot) in one shot from 128 actions,
|
| 6 |
+
decompose into:
|
| 7 |
+
Step 1: Predict operation type (4: malloc, free, write_freed, noop)
|
| 8 |
+
Step 2: For the chosen op, pick parameters using simple heuristics
|
| 9 |
+
|
| 10 |
+
This makes imitation learning tractable (4-class instead of 128-class).
|
| 11 |
+
The model learns the HIGH-LEVEL STRATEGY (when to alloc vs free vs UAF-write),
|
| 12 |
+
not the low-level details (which slot, which size).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
import numpy as np
|
| 19 |
+
import random
|
| 20 |
+
import copy
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "simulator"))
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "model"))
|
| 25 |
+
|
| 26 |
+
from heap_sim import HeapSimulator
|
| 27 |
+
from trm_heap import RMSNorm, SwiGLU, RecursionBlock
|
| 28 |
+
|
| 29 |
+
# 4 operation types
|
| 30 |
+
OP_MALLOC = 0
|
| 31 |
+
OP_FREE = 1
|
| 32 |
+
OP_WRITE_FREED = 2
|
| 33 |
+
OP_NOOP = 3
|
| 34 |
+
N_OPS = 4
|
| 35 |
+
|
| 36 |
+
SIZES = [0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class SimpleHeapTRM(torch.nn.Module):
|
| 40 |
+
"""TRM that predicts operation type (4 classes)."""
|
| 41 |
+
|
| 42 |
+
def __init__(self, vocab_size=64, hidden_dim=128, seq_len=512,
|
| 43 |
+
n_outer=2, n_inner=3):
|
| 44 |
+
super().__init__()
|
| 45 |
+
self.embed = torch.nn.Embedding(vocab_size, hidden_dim)
|
| 46 |
+
self.y_init = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 47 |
+
self.z_init = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 48 |
+
self.block_z = RecursionBlock(hidden_dim)
|
| 49 |
+
self.block_y = RecursionBlock(hidden_dim)
|
| 50 |
+
self.pos_embed = torch.nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 51 |
+
self.out_norm = RMSNorm(hidden_dim)
|
| 52 |
+
self.n_outer = n_outer
|
| 53 |
+
self.n_inner = n_inner
|
| 54 |
+
|
| 55 |
+
# 4-class output
|
| 56 |
+
self.head = torch.nn.Linear(hidden_dim, N_OPS)
|
| 57 |
+
|
| 58 |
+
def forward(self, x):
|
| 59 |
+
B = x.shape[0]
|
| 60 |
+
h = self.embed(x.reshape(B, -1)) + self.pos_embed
|
| 61 |
+
y = self.y_init.expand(B, -1, -1)
|
| 62 |
+
z = self.z_init.expand(B, -1, -1)
|
| 63 |
+
for _ in range(self.n_outer):
|
| 64 |
+
for _ in range(self.n_inner):
|
| 65 |
+
z = z + self.block_z(h + y + z)
|
| 66 |
+
y = y + self.block_y(y + z)
|
| 67 |
+
pooled = self.out_norm(y).mean(dim=1)
|
| 68 |
+
return self.head(pooled)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def execute_op(sim: HeapSimulator, op_type: int) -> bool:
|
| 72 |
+
"""Execute an operation with automatic parameter selection."""
|
| 73 |
+
used_slots = set(sim.slots.keys())
|
| 74 |
+
free_slots = [s for s in range(8) if s not in used_slots]
|
| 75 |
+
occupied_slots = list(used_slots)
|
| 76 |
+
|
| 77 |
+
# Find freed-but-still-tracked slots (UAF candidates)
|
| 78 |
+
freed_slots = []
|
| 79 |
+
for slot in occupied_slots:
|
| 80 |
+
addr = sim.slots[slot]
|
| 81 |
+
chunk_addr = addr - 16
|
| 82 |
+
chunk = sim.chunks.get(chunk_addr)
|
| 83 |
+
if chunk and not chunk.allocated:
|
| 84 |
+
freed_slots.append(slot)
|
| 85 |
+
|
| 86 |
+
alloc_slots = [s for s in occupied_slots if s not in freed_slots]
|
| 87 |
+
|
| 88 |
+
if op_type == OP_MALLOC:
|
| 89 |
+
if not free_slots:
|
| 90 |
+
return False
|
| 91 |
+
slot = random.choice(free_slots)
|
| 92 |
+
size = random.choice(SIZES)
|
| 93 |
+
return sim.malloc(size, slot=slot) is not None
|
| 94 |
+
|
| 95 |
+
elif op_type == OP_FREE:
|
| 96 |
+
if not alloc_slots:
|
| 97 |
+
return False
|
| 98 |
+
slot = random.choice(alloc_slots)
|
| 99 |
+
return sim.free(user_addr=sim.slots[slot], slot=slot)
|
| 100 |
+
|
| 101 |
+
elif op_type == OP_WRITE_FREED:
|
| 102 |
+
if not freed_slots or len(occupied_slots) < 2:
|
| 103 |
+
return False
|
| 104 |
+
src = random.choice(freed_slots)
|
| 105 |
+
targets = [s for s in occupied_slots if s != src]
|
| 106 |
+
if not targets:
|
| 107 |
+
return False
|
| 108 |
+
tgt = random.choice(targets)
|
| 109 |
+
return sim.write_to_freed(sim.slots[src], sim.slots[tgt])
|
| 110 |
+
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ============================================================
|
| 115 |
+
# EXPERT DEMOS (as operation type sequences)
|
| 116 |
+
# ============================================================
|
| 117 |
+
|
| 118 |
+
TCACHE_POISON_OPS = [
|
| 119 |
+
OP_MALLOC, # alloc A
|
| 120 |
+
OP_MALLOC, # alloc B
|
| 121 |
+
OP_MALLOC, # alloc C (guard)
|
| 122 |
+
OP_FREE, # free A -> tcache
|
| 123 |
+
OP_FREE, # free B -> tcache
|
| 124 |
+
OP_WRITE_FREED, # UAF: corrupt B's fd
|
| 125 |
+
OP_MALLOC, # alloc from tcache (gets B)
|
| 126 |
+
OP_MALLOC, # alloc from tcache (gets poisoned addr!)
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def generate_demos(n_variants=200) -> tuple:
|
| 131 |
+
"""Generate demo (state, op_type) pairs by running through simulator."""
|
| 132 |
+
states = []
|
| 133 |
+
labels = []
|
| 134 |
+
|
| 135 |
+
for _ in range(n_variants):
|
| 136 |
+
sim = HeapSimulator()
|
| 137 |
+
ops = list(TCACHE_POISON_OPS)
|
| 138 |
+
|
| 139 |
+
for op in ops:
|
| 140 |
+
grid = sim.state_to_grid()
|
| 141 |
+
states.append(grid)
|
| 142 |
+
labels.append(op)
|
| 143 |
+
execute_op(sim, op)
|
| 144 |
+
|
| 145 |
+
return np.stack(states), np.array(labels, dtype=np.int64)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def train(model, X, y, epochs=200, lr=1e-3, bs=64):
|
| 149 |
+
"""Train with cross-entropy on 4-class op prediction."""
|
| 150 |
+
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 151 |
+
X_t = torch.from_numpy(X).long()
|
| 152 |
+
y_t = torch.from_numpy(y).long()
|
| 153 |
+
n = len(X_t)
|
| 154 |
+
|
| 155 |
+
for ep in range(1, epochs + 1):
|
| 156 |
+
model.train()
|
| 157 |
+
perm = torch.randperm(n)
|
| 158 |
+
total_loss = 0
|
| 159 |
+
correct = 0
|
| 160 |
+
nb = 0
|
| 161 |
+
|
| 162 |
+
for i in range(0, n, bs):
|
| 163 |
+
idx = perm[i:i+bs]
|
| 164 |
+
logits = model(X_t[idx])
|
| 165 |
+
loss = F.cross_entropy(logits, y_t[idx])
|
| 166 |
+
opt.zero_grad()
|
| 167 |
+
loss.backward()
|
| 168 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 169 |
+
opt.step()
|
| 170 |
+
total_loss += loss.item()
|
| 171 |
+
correct += (logits.argmax(1) == y_t[idx]).sum().item()
|
| 172 |
+
nb += 1
|
| 173 |
+
|
| 174 |
+
if ep % 20 == 0 or ep == 1:
|
| 175 |
+
acc = correct / n
|
| 176 |
+
print(f" Epoch {ep:3d} | loss={total_loss/nb:.4f} | acc={acc:.3f}")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def evaluate_policy(model, goal="duplicate_alloc", n_trials=200, max_steps=20):
|
| 180 |
+
"""Run the learned policy and check success rate."""
|
| 181 |
+
model.eval()
|
| 182 |
+
n_achieved = 0
|
| 183 |
+
lengths = []
|
| 184 |
+
|
| 185 |
+
for _ in range(n_trials):
|
| 186 |
+
sim = HeapSimulator()
|
| 187 |
+
for step in range(max_steps):
|
| 188 |
+
grid = sim.state_to_grid()
|
| 189 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 190 |
+
with torch.no_grad():
|
| 191 |
+
logits = model(x)
|
| 192 |
+
op = logits.argmax(1).item()
|
| 193 |
+
|
| 194 |
+
execute_op(sim, op)
|
| 195 |
+
|
| 196 |
+
prims = sim.check_primitives()
|
| 197 |
+
if prims.get(goal, False):
|
| 198 |
+
n_achieved += 1
|
| 199 |
+
lengths.append(step + 1)
|
| 200 |
+
break
|
| 201 |
+
|
| 202 |
+
return n_achieved, lengths
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main():
|
| 206 |
+
print("=== Generating demos ===")
|
| 207 |
+
X, y = generate_demos(300)
|
| 208 |
+
print(f"Data: {len(X)} samples")
|
| 209 |
+
print(f"Op distribution: malloc={sum(y==0)}, free={sum(y==1)}, "
|
| 210 |
+
f"write_freed={sum(y==2)}, noop={sum(y==3)}")
|
| 211 |
+
|
| 212 |
+
print("\n=== Training (4-class op prediction) ===")
|
| 213 |
+
model = SimpleHeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 214 |
+
params = sum(p.numel() for p in model.parameters())
|
| 215 |
+
print(f"Parameters: {params:,}")
|
| 216 |
+
train(model, X, y, epochs=200, lr=1e-3)
|
| 217 |
+
|
| 218 |
+
print("\n=== Evaluating policy ===")
|
| 219 |
+
n_achieved, lengths = evaluate_policy(model, n_trials=200, max_steps=20)
|
| 220 |
+
print(f"Success rate: {n_achieved}/200 ({n_achieved/2:.0f}%)")
|
| 221 |
+
if lengths:
|
| 222 |
+
print(f"Avg steps: {np.mean(lengths):.1f}, min={min(lengths)}, max={max(lengths)}")
|
| 223 |
+
|
| 224 |
+
# Also try with beam-like approach: run 10x and take best
|
| 225 |
+
print("\n=== Best-of-10 evaluation ===")
|
| 226 |
+
n_achieved_bo10 = 0
|
| 227 |
+
for trial in range(100):
|
| 228 |
+
found = False
|
| 229 |
+
for attempt in range(10):
|
| 230 |
+
sim = HeapSimulator()
|
| 231 |
+
for step in range(20):
|
| 232 |
+
grid = sim.state_to_grid()
|
| 233 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 234 |
+
with torch.no_grad():
|
| 235 |
+
logits = model(x)
|
| 236 |
+
probs = F.softmax(logits / 0.5, dim=1)
|
| 237 |
+
op = torch.multinomial(probs, 1).item()
|
| 238 |
+
execute_op(sim, op)
|
| 239 |
+
if sim.check_primitives().get("duplicate_alloc"):
|
| 240 |
+
found = True
|
| 241 |
+
break
|
| 242 |
+
if found:
|
| 243 |
+
break
|
| 244 |
+
if found:
|
| 245 |
+
n_achieved_bo10 += 1
|
| 246 |
+
|
| 247 |
+
print(f"Best-of-10 success: {n_achieved_bo10}/100 ({n_achieved_bo10}%)")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
if __name__ == "__main__":
|
| 251 |
+
main()
|
agent/train_enhanced.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
train_enhanced.py - Train agent with enhanced grid encoding on real binary.
|
| 4 |
+
|
| 5 |
+
Key improvements:
|
| 6 |
+
1. Enhanced grid with history + summary rows
|
| 7 |
+
2. Diverse-length sequences (not always 3+2+1+2)
|
| 8 |
+
3. Train on real binary dumps
|
| 9 |
+
4. Evaluate on real binary with stateful grid encoder
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
import os
|
| 14 |
+
import json
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
import time
|
| 18 |
+
import random
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from collections import deque
|
| 24 |
+
|
| 25 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 26 |
+
sys.path.insert(0, str(ROOT / "agent"))
|
| 27 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 28 |
+
|
| 29 |
+
from simple_agent import SimpleHeapTRM, OP_MALLOC, OP_FREE, OP_WRITE_FREED, SIZES
|
| 30 |
+
from enhanced_grid import GridEncoder
|
| 31 |
+
|
| 32 |
+
BINARY = ROOT / "ctf" / "vuln_heap"
|
| 33 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_and_dump(commands: list) -> list:
|
| 37 |
+
"""Run commands on real binary, return dump states."""
|
| 38 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 39 |
+
env = os.environ.copy()
|
| 40 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 41 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 42 |
+
|
| 43 |
+
input_str = "\n".join(commands) + "\n5\n"
|
| 44 |
+
subprocess.run([str(BINARY)], input=input_str.encode(),
|
| 45 |
+
env=env, capture_output=True, timeout=10)
|
| 46 |
+
|
| 47 |
+
states = []
|
| 48 |
+
if os.path.exists(dump_path):
|
| 49 |
+
with open(dump_path) as f:
|
| 50 |
+
for line in f:
|
| 51 |
+
line = line.strip()
|
| 52 |
+
if line:
|
| 53 |
+
states.append(json.loads(line))
|
| 54 |
+
os.unlink(dump_path)
|
| 55 |
+
return states
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def gen_exploit_varied(n_pre_alloc=None) -> tuple:
|
| 59 |
+
"""Generate exploit with VARIED number of pre-allocations.
|
| 60 |
+
|
| 61 |
+
This forces the model to learn structural triggers, not positional ones.
|
| 62 |
+
"""
|
| 63 |
+
size = random.choice(SIZES)
|
| 64 |
+
slots = random.sample(range(16), 8)
|
| 65 |
+
|
| 66 |
+
# Variable number of pre-allocations (2-6)
|
| 67 |
+
if n_pre_alloc is None:
|
| 68 |
+
n_pre_alloc = random.randint(2, 6)
|
| 69 |
+
|
| 70 |
+
commands = []
|
| 71 |
+
labels = []
|
| 72 |
+
|
| 73 |
+
# Phase 1: Allocate (variable count)
|
| 74 |
+
alloc_slots = []
|
| 75 |
+
for i in range(n_pre_alloc):
|
| 76 |
+
s = slots[i]
|
| 77 |
+
commands.append(f"1 {s} {size}")
|
| 78 |
+
labels.append(OP_MALLOC)
|
| 79 |
+
alloc_slots.append(s)
|
| 80 |
+
|
| 81 |
+
# Phase 2: Free exactly 2 (for tcache poison)
|
| 82 |
+
free_targets = random.sample(alloc_slots, 2)
|
| 83 |
+
for s in free_targets:
|
| 84 |
+
commands.append(f"4 {s}")
|
| 85 |
+
labels.append(OP_FREE)
|
| 86 |
+
|
| 87 |
+
# Phase 3: UAF write
|
| 88 |
+
commands.append(f"2 {free_targets[1]} {'41' * 8}")
|
| 89 |
+
labels.append(OP_WRITE_FREED)
|
| 90 |
+
|
| 91 |
+
# Phase 4: Drain tcache (2 mallocs)
|
| 92 |
+
drain_slots = [s for s in slots if s not in alloc_slots][:2]
|
| 93 |
+
for s in drain_slots:
|
| 94 |
+
commands.append(f"1 {s} {size}")
|
| 95 |
+
labels.append(OP_MALLOC)
|
| 96 |
+
|
| 97 |
+
return commands, labels
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def gen_benign_varied() -> tuple:
|
| 101 |
+
"""Generate benign sequence with varied length."""
|
| 102 |
+
n_ops = random.randint(4, 15)
|
| 103 |
+
commands = []
|
| 104 |
+
labels = []
|
| 105 |
+
allocated = {}
|
| 106 |
+
|
| 107 |
+
for _ in range(n_ops):
|
| 108 |
+
if not allocated or random.random() < 0.55:
|
| 109 |
+
free_slots = [s for s in range(16) if s not in allocated]
|
| 110 |
+
if not free_slots:
|
| 111 |
+
break
|
| 112 |
+
slot = random.choice(free_slots)
|
| 113 |
+
size = random.choice(SIZES)
|
| 114 |
+
commands.append(f"1 {slot} {size}")
|
| 115 |
+
labels.append(OP_MALLOC)
|
| 116 |
+
allocated[slot] = size
|
| 117 |
+
else:
|
| 118 |
+
slot = random.choice(list(allocated.keys()))
|
| 119 |
+
commands.append(f"4 {slot}")
|
| 120 |
+
labels.append(OP_FREE)
|
| 121 |
+
del allocated[slot]
|
| 122 |
+
|
| 123 |
+
return commands, labels
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def label_from_dump_op(op_str: str) -> int:
|
| 127 |
+
"""Derive label from the dump's operation field."""
|
| 128 |
+
op = op_str.lower()
|
| 129 |
+
if op in ("malloc", "calloc", "realloc"):
|
| 130 |
+
return OP_MALLOC
|
| 131 |
+
elif op == "free":
|
| 132 |
+
return OP_FREE
|
| 133 |
+
return OP_MALLOC # default
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def collect_data(n_exploit=300, n_benign=200) -> tuple:
|
| 137 |
+
"""Collect training data with enhanced grid encoding.
|
| 138 |
+
|
| 139 |
+
IMPORTANT: Labels are derived from dump operations, not from our
|
| 140 |
+
expected command sequence. This avoids misalignment when the harness
|
| 141 |
+
captures fewer states than commands (e.g., edit doesn't trigger dump).
|
| 142 |
+
|
| 143 |
+
For exploit sequences, we label based on what SHOULD happen NEXT:
|
| 144 |
+
- State after last malloc in setup phase -> next op is FREE
|
| 145 |
+
- State after last free -> next op is WRITE_FREED
|
| 146 |
+
- State after write -> next op is MALLOC (drain)
|
| 147 |
+
"""
|
| 148 |
+
all_grids = []
|
| 149 |
+
all_labels = []
|
| 150 |
+
|
| 151 |
+
print(f" Generating {n_exploit} varied exploit sequences...")
|
| 152 |
+
for i in range(n_exploit):
|
| 153 |
+
commands, op_labels = gen_exploit_varied()
|
| 154 |
+
states = run_and_dump(commands)
|
| 155 |
+
|
| 156 |
+
if len(states) < 3:
|
| 157 |
+
continue
|
| 158 |
+
|
| 159 |
+
encoder = GridEncoder()
|
| 160 |
+
|
| 161 |
+
# For each state, determine what the NEXT action should be
|
| 162 |
+
# based on phase analysis of the dump
|
| 163 |
+
n_alloc_seen = 0
|
| 164 |
+
n_free_seen = 0
|
| 165 |
+
write_done = False
|
| 166 |
+
|
| 167 |
+
for j, state in enumerate(states):
|
| 168 |
+
op = state.get("operation", "malloc")
|
| 169 |
+
|
| 170 |
+
# Determine current phase from what we've observed
|
| 171 |
+
if "free" in op.lower():
|
| 172 |
+
n_free_seen += 1
|
| 173 |
+
|
| 174 |
+
# Determine what the NEXT action should be
|
| 175 |
+
if n_free_seen == 0:
|
| 176 |
+
# Still in alloc phase
|
| 177 |
+
# Count allocated chunks of same size
|
| 178 |
+
chunks = state.get("chunks", [])
|
| 179 |
+
n_alloc = sum(1 for c in chunks if c.get("state") == 1)
|
| 180 |
+
n_freed = sum(1 for c in chunks if c.get("state") == 2)
|
| 181 |
+
|
| 182 |
+
if n_alloc >= 2 and n_freed == 0:
|
| 183 |
+
# Enough allocs, should start freeing
|
| 184 |
+
# But only label as FREE if we're near the transition
|
| 185 |
+
# Use a probabilistic approach: more allocs = more likely to free
|
| 186 |
+
if j >= len(states) * 0.3:
|
| 187 |
+
next_label = OP_FREE
|
| 188 |
+
else:
|
| 189 |
+
next_label = OP_MALLOC
|
| 190 |
+
else:
|
| 191 |
+
next_label = OP_MALLOC
|
| 192 |
+
elif n_free_seen >= 2 and not write_done:
|
| 193 |
+
next_label = OP_WRITE_FREED
|
| 194 |
+
write_done = True
|
| 195 |
+
else:
|
| 196 |
+
next_label = OP_MALLOC # drain phase
|
| 197 |
+
|
| 198 |
+
grid = encoder.encode(state)
|
| 199 |
+
all_grids.append(grid)
|
| 200 |
+
all_labels.append(next_label)
|
| 201 |
+
|
| 202 |
+
# Record for history
|
| 203 |
+
actual_op = OP_MALLOC if "alloc" in op.lower() or "calloc" in op.lower() else \
|
| 204 |
+
OP_FREE if "free" in op.lower() else OP_MALLOC
|
| 205 |
+
encoder.record_action(actual_op, state.get("target_size", 0))
|
| 206 |
+
|
| 207 |
+
print(f" Generating {n_benign} varied benign sequences...")
|
| 208 |
+
for i in range(n_benign):
|
| 209 |
+
commands, op_labels = gen_benign_varied()
|
| 210 |
+
states = run_and_dump(commands)
|
| 211 |
+
|
| 212 |
+
encoder = GridEncoder()
|
| 213 |
+
for j, state in enumerate(states):
|
| 214 |
+
op = state.get("operation", "malloc")
|
| 215 |
+
# For benign: label with what actually happens next
|
| 216 |
+
if j + 1 < len(states):
|
| 217 |
+
next_op = states[j + 1].get("operation", "malloc")
|
| 218 |
+
next_label = label_from_dump_op(next_op)
|
| 219 |
+
else:
|
| 220 |
+
next_label = OP_MALLOC # default
|
| 221 |
+
|
| 222 |
+
grid = encoder.encode(state)
|
| 223 |
+
all_grids.append(grid)
|
| 224 |
+
all_labels.append(next_label)
|
| 225 |
+
|
| 226 |
+
actual_op = label_from_dump_op(op)
|
| 227 |
+
encoder.record_action(actual_op, state.get("target_size", 0))
|
| 228 |
+
|
| 229 |
+
X = np.stack(all_grids)
|
| 230 |
+
y = np.array(all_labels, dtype=np.int64)
|
| 231 |
+
return X, y
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def train(model, X, y, epochs=150, lr=1e-3, bs=64):
|
| 235 |
+
"""Train with cross-entropy."""
|
| 236 |
+
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 237 |
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
|
| 238 |
+
X_t = torch.from_numpy(X).long()
|
| 239 |
+
y_t = torch.from_numpy(y).long()
|
| 240 |
+
n = len(X_t)
|
| 241 |
+
|
| 242 |
+
for ep in range(1, epochs + 1):
|
| 243 |
+
model.train()
|
| 244 |
+
perm = torch.randperm(n)
|
| 245 |
+
total_loss = 0
|
| 246 |
+
correct = 0
|
| 247 |
+
nb = 0
|
| 248 |
+
|
| 249 |
+
for i in range(0, n, bs):
|
| 250 |
+
idx = perm[i:i+bs]
|
| 251 |
+
logits = model(X_t[idx])
|
| 252 |
+
loss = F.cross_entropy(logits, y_t[idx])
|
| 253 |
+
opt.zero_grad()
|
| 254 |
+
loss.backward()
|
| 255 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 256 |
+
opt.step()
|
| 257 |
+
total_loss += loss.item()
|
| 258 |
+
correct += (logits.argmax(1) == y_t[idx]).sum().item()
|
| 259 |
+
nb += 1
|
| 260 |
+
|
| 261 |
+
sched.step()
|
| 262 |
+
if ep % 20 == 0 or ep == 1:
|
| 263 |
+
print(f" Epoch {ep:3d} | loss={total_loss/nb:.4f} | acc={correct/n:.3f}")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def evaluate_live(model, n_trials=50, max_steps=20, temperature=0.3):
|
| 267 |
+
"""Evaluate on live binary with stateful grid encoder."""
|
| 268 |
+
model.eval()
|
| 269 |
+
n_uaf_write = 0
|
| 270 |
+
n_correct_sequence = 0
|
| 271 |
+
op_sequences = []
|
| 272 |
+
|
| 273 |
+
for trial in range(n_trials):
|
| 274 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 275 |
+
env = os.environ.copy()
|
| 276 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 277 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 278 |
+
|
| 279 |
+
proc = subprocess.Popen(
|
| 280 |
+
[str(BINARY)], stdin=subprocess.PIPE,
|
| 281 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
| 282 |
+
|
| 283 |
+
encoder = GridEncoder()
|
| 284 |
+
slots = {}
|
| 285 |
+
freed = set()
|
| 286 |
+
ops_done = []
|
| 287 |
+
did_write = False
|
| 288 |
+
|
| 289 |
+
for step in range(max_steps):
|
| 290 |
+
time.sleep(0.01)
|
| 291 |
+
|
| 292 |
+
# Read current state
|
| 293 |
+
state = None
|
| 294 |
+
try:
|
| 295 |
+
with open(dump_path) as f:
|
| 296 |
+
lines = f.readlines()
|
| 297 |
+
if lines:
|
| 298 |
+
state = json.loads(lines[-1].strip())
|
| 299 |
+
except Exception:
|
| 300 |
+
pass
|
| 301 |
+
|
| 302 |
+
if state is None:
|
| 303 |
+
state = {"chunks": []}
|
| 304 |
+
|
| 305 |
+
grid = encoder.encode(state)
|
| 306 |
+
|
| 307 |
+
# Predict
|
| 308 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 309 |
+
with torch.no_grad():
|
| 310 |
+
logits = model(x)
|
| 311 |
+
if temperature == 0:
|
| 312 |
+
op = logits.argmax(1).item()
|
| 313 |
+
else:
|
| 314 |
+
probs = F.softmax(logits / temperature, dim=1)
|
| 315 |
+
op = torch.multinomial(probs, 1).item()
|
| 316 |
+
|
| 317 |
+
# Execute
|
| 318 |
+
free_slots = [s for s in range(8) if s not in slots]
|
| 319 |
+
alloc_slots = [s for s, v in slots.items() if v and s not in freed]
|
| 320 |
+
freed_slots = list(freed)
|
| 321 |
+
|
| 322 |
+
cmd = None
|
| 323 |
+
size = 0
|
| 324 |
+
|
| 325 |
+
if op == OP_MALLOC and free_slots:
|
| 326 |
+
s = random.choice(free_slots)
|
| 327 |
+
size = random.choice(SIZES)
|
| 328 |
+
cmd = f"1 {s} {size}"
|
| 329 |
+
slots[s] = True
|
| 330 |
+
ops_done.append("M")
|
| 331 |
+
|
| 332 |
+
elif op == OP_FREE and alloc_slots:
|
| 333 |
+
s = random.choice(alloc_slots)
|
| 334 |
+
cmd = f"4 {s}"
|
| 335 |
+
slots[s] = False
|
| 336 |
+
freed.add(s)
|
| 337 |
+
ops_done.append("F")
|
| 338 |
+
|
| 339 |
+
elif op == OP_WRITE_FREED and freed_slots:
|
| 340 |
+
s = random.choice(freed_slots)
|
| 341 |
+
cmd = f"2 {s} {'41' * 8}"
|
| 342 |
+
did_write = True
|
| 343 |
+
ops_done.append("W")
|
| 344 |
+
|
| 345 |
+
else:
|
| 346 |
+
ops_done.append("x") # skipped
|
| 347 |
+
|
| 348 |
+
if cmd:
|
| 349 |
+
proc.stdin.write((cmd + "\n").encode())
|
| 350 |
+
proc.stdin.flush()
|
| 351 |
+
|
| 352 |
+
encoder.record_action(op, size)
|
| 353 |
+
|
| 354 |
+
# Cleanup
|
| 355 |
+
try:
|
| 356 |
+
proc.stdin.write(b"5\n")
|
| 357 |
+
proc.stdin.flush()
|
| 358 |
+
proc.wait(timeout=2)
|
| 359 |
+
except Exception:
|
| 360 |
+
proc.kill()
|
| 361 |
+
|
| 362 |
+
# Check for UAF write
|
| 363 |
+
if did_write:
|
| 364 |
+
try:
|
| 365 |
+
with open(dump_path) as f:
|
| 366 |
+
lines = f.readlines()
|
| 367 |
+
for line in lines:
|
| 368 |
+
state = json.loads(line.strip())
|
| 369 |
+
for c in state.get("chunks", []):
|
| 370 |
+
if c.get("fd", 0) == 0x4141414141414141:
|
| 371 |
+
n_uaf_write += 1
|
| 372 |
+
break
|
| 373 |
+
else:
|
| 374 |
+
continue
|
| 375 |
+
break
|
| 376 |
+
except Exception:
|
| 377 |
+
pass
|
| 378 |
+
|
| 379 |
+
# Check if sequence pattern is correct (M+, F+, W, M+)
|
| 380 |
+
seq = "".join(ops_done)
|
| 381 |
+
# Valid pattern: starts with Ms, has Fs, has W, ends with Ms
|
| 382 |
+
import re
|
| 383 |
+
if re.match(r'^M+F+WM+', seq.replace("x", "")):
|
| 384 |
+
n_correct_sequence += 1
|
| 385 |
+
|
| 386 |
+
op_sequences.append("".join(ops_done))
|
| 387 |
+
|
| 388 |
+
if os.path.exists(dump_path):
|
| 389 |
+
os.unlink(dump_path)
|
| 390 |
+
|
| 391 |
+
return n_uaf_write, n_correct_sequence, op_sequences
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def main():
|
| 395 |
+
print("=== Collecting training data with enhanced grids ===")
|
| 396 |
+
X, y = collect_data(n_exploit=300, n_benign=150)
|
| 397 |
+
print(f"Data: {len(X)} samples")
|
| 398 |
+
print(f"Op dist: M={sum(y==0)}, F={sum(y==1)}, W={sum(y==2)}")
|
| 399 |
+
|
| 400 |
+
print("\n=== Training ===")
|
| 401 |
+
model = SimpleHeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 402 |
+
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
|
| 403 |
+
train(model, X, y, epochs=150, lr=1e-3)
|
| 404 |
+
|
| 405 |
+
print("\n=== Live evaluation (greedy, 50 trials) ===")
|
| 406 |
+
n_write, n_seq, seqs = evaluate_live(model, n_trials=50, temperature=0.0)
|
| 407 |
+
print(f"UAF writes achieved: {n_write}/50 ({n_write*2}%)")
|
| 408 |
+
print(f"Correct op sequences: {n_seq}/50 ({n_seq*2}%)")
|
| 409 |
+
print("Sample sequences:")
|
| 410 |
+
for s in seqs[:10]:
|
| 411 |
+
print(f" {s}")
|
| 412 |
+
|
| 413 |
+
print("\n=== Live evaluation (stochastic, 50 trials) ===")
|
| 414 |
+
n_write2, n_seq2, seqs2 = evaluate_live(model, n_trials=50, temperature=0.5)
|
| 415 |
+
print(f"UAF writes achieved: {n_write2}/50 ({n_write2*2}%)")
|
| 416 |
+
print(f"Correct op sequences: {n_seq2}/50 ({n_seq2*2}%)")
|
| 417 |
+
print("Sample sequences:")
|
| 418 |
+
for s in seqs2[:10]:
|
| 419 |
+
print(f" {s}")
|
| 420 |
+
|
| 421 |
+
# Best-of-5
|
| 422 |
+
print("\n=== Best-of-5 (20 trials) ===")
|
| 423 |
+
n_bo5 = 0
|
| 424 |
+
for trial in range(20):
|
| 425 |
+
found = False
|
| 426 |
+
for attempt in range(5):
|
| 427 |
+
nw, ns, _ = evaluate_live(model, n_trials=1, temperature=0.5)
|
| 428 |
+
if nw > 0:
|
| 429 |
+
found = True
|
| 430 |
+
break
|
| 431 |
+
if found:
|
| 432 |
+
n_bo5 += 1
|
| 433 |
+
print(f"Best-of-5: {n_bo5}/20 ({n_bo5*5}%)")
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
if __name__ == "__main__":
|
| 437 |
+
main()
|
agent/train_on_real.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
train_on_real.py - Train TRM agent on REAL binary heap dumps.
|
| 4 |
+
|
| 5 |
+
Instead of simulator grids, we:
|
| 6 |
+
1. Drive vuln_heap with expert exploit sequences via LD_PRELOAD harness
|
| 7 |
+
2. Capture real heap state dumps at each step
|
| 8 |
+
3. Encode with dataset_gen.state_to_grid (same encoder used for detection)
|
| 9 |
+
4. Train op-prediction model on real grids
|
| 10 |
+
|
| 11 |
+
This eliminates the sim-to-real gap.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
import os
|
| 16 |
+
import json
|
| 17 |
+
import subprocess
|
| 18 |
+
import tempfile
|
| 19 |
+
import time
|
| 20 |
+
import random
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 27 |
+
sys.path.insert(0, str(ROOT / "dataset"))
|
| 28 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 29 |
+
sys.path.insert(0, str(ROOT / "agent"))
|
| 30 |
+
|
| 31 |
+
from dataset_gen import state_to_grid
|
| 32 |
+
from simple_agent import SimpleHeapTRM, OP_MALLOC, OP_FREE, OP_WRITE_FREED, SIZES
|
| 33 |
+
|
| 34 |
+
BINARY = ROOT / "ctf" / "vuln_heap"
|
| 35 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_sequence_on_real_binary(commands: list) -> list:
|
| 39 |
+
"""Run a command sequence on the real binary, return per-step heap dumps."""
|
| 40 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 41 |
+
|
| 42 |
+
env = os.environ.copy()
|
| 43 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 44 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 45 |
+
|
| 46 |
+
input_str = "\n".join(commands) + "\n5\n" # 5 = exit
|
| 47 |
+
|
| 48 |
+
proc = subprocess.run(
|
| 49 |
+
[str(BINARY)],
|
| 50 |
+
input=input_str.encode(),
|
| 51 |
+
env=env,
|
| 52 |
+
capture_output=True,
|
| 53 |
+
timeout=10,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
states = []
|
| 57 |
+
if os.path.exists(dump_path):
|
| 58 |
+
with open(dump_path) as f:
|
| 59 |
+
for line in f:
|
| 60 |
+
line = line.strip()
|
| 61 |
+
if line:
|
| 62 |
+
states.append(json.loads(line))
|
| 63 |
+
os.unlink(dump_path)
|
| 64 |
+
|
| 65 |
+
return states
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def generate_exploit_sequence(variant=0) -> tuple:
|
| 69 |
+
"""Generate an exploit command sequence + op labels.
|
| 70 |
+
|
| 71 |
+
Returns (commands, op_labels) where op_labels[i] is the op type
|
| 72 |
+
for the action that PRODUCED state[i].
|
| 73 |
+
"""
|
| 74 |
+
size = random.choice(SIZES)
|
| 75 |
+
slots = random.sample(range(16), 5) # pick 5 random slots
|
| 76 |
+
s0, s1, s2, s3, s4 = slots
|
| 77 |
+
|
| 78 |
+
size_hex = hex(size)
|
| 79 |
+
|
| 80 |
+
commands = [
|
| 81 |
+
f"1 {s0} {size}", # malloc s0
|
| 82 |
+
f"1 {s1} {size}", # malloc s1
|
| 83 |
+
f"1 {s2} {size}", # malloc s2 (guard)
|
| 84 |
+
f"4 {s0}", # free s0
|
| 85 |
+
f"4 {s1}", # free s1
|
| 86 |
+
f"2 {s1} {'41' * 8}", # UAF edit s1 (write_freed)
|
| 87 |
+
f"1 {s3} {size}", # malloc s3 (from tcache, gets s1's chunk)
|
| 88 |
+
f"1 {s4} {size}", # malloc s4 (from tcache, gets poisoned)
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
# One op label per command
|
| 92 |
+
op_labels = [
|
| 93 |
+
OP_MALLOC,
|
| 94 |
+
OP_MALLOC,
|
| 95 |
+
OP_MALLOC,
|
| 96 |
+
OP_FREE,
|
| 97 |
+
OP_FREE,
|
| 98 |
+
OP_WRITE_FREED,
|
| 99 |
+
OP_MALLOC,
|
| 100 |
+
OP_MALLOC,
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
return commands, op_labels
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def generate_benign_sequence(n_ops=8) -> tuple:
|
| 107 |
+
"""Random benign sequence (alloc and free, no UAF)."""
|
| 108 |
+
commands = []
|
| 109 |
+
labels = []
|
| 110 |
+
allocated = {}
|
| 111 |
+
|
| 112 |
+
for _ in range(n_ops):
|
| 113 |
+
if not allocated or random.random() < 0.6:
|
| 114 |
+
# malloc
|
| 115 |
+
slot = random.choice([s for s in range(16) if s not in allocated])
|
| 116 |
+
size = random.choice(SIZES)
|
| 117 |
+
commands.append(f"1 {slot} {size}")
|
| 118 |
+
labels.append(OP_MALLOC)
|
| 119 |
+
allocated[slot] = size
|
| 120 |
+
else:
|
| 121 |
+
# free
|
| 122 |
+
slot = random.choice(list(allocated.keys()))
|
| 123 |
+
commands.append(f"4 {slot}")
|
| 124 |
+
labels.append(OP_FREE)
|
| 125 |
+
del allocated[slot]
|
| 126 |
+
|
| 127 |
+
return commands, labels
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def collect_training_data(n_exploit=200, n_benign=100) -> tuple:
|
| 131 |
+
"""Generate training data from real binary runs."""
|
| 132 |
+
all_grids = []
|
| 133 |
+
all_labels = []
|
| 134 |
+
|
| 135 |
+
print(f" Generating {n_exploit} exploit sequences...")
|
| 136 |
+
for i in range(n_exploit):
|
| 137 |
+
commands, op_labels = generate_exploit_sequence(variant=i)
|
| 138 |
+
states = run_sequence_on_real_binary(commands)
|
| 139 |
+
|
| 140 |
+
# Match states to labels (harness may capture extra states from libc init)
|
| 141 |
+
# We label from the back: last len(op_labels) states match our commands
|
| 142 |
+
n = min(len(states), len(op_labels))
|
| 143 |
+
if n > 0:
|
| 144 |
+
for j in range(n):
|
| 145 |
+
grid = state_to_grid(states[-(n-j)])
|
| 146 |
+
all_grids.append(grid)
|
| 147 |
+
all_labels.append(op_labels[j])
|
| 148 |
+
|
| 149 |
+
print(f" Generating {n_benign} benign sequences...")
|
| 150 |
+
for i in range(n_benign):
|
| 151 |
+
n_ops = random.randint(5, 12)
|
| 152 |
+
commands, op_labels = generate_benign_sequence(n_ops)
|
| 153 |
+
states = run_sequence_on_real_binary(commands)
|
| 154 |
+
|
| 155 |
+
n = min(len(states), len(op_labels))
|
| 156 |
+
if n > 0:
|
| 157 |
+
for j in range(n):
|
| 158 |
+
grid = state_to_grid(states[-(n-j)])
|
| 159 |
+
all_grids.append(grid)
|
| 160 |
+
all_labels.append(op_labels[j])
|
| 161 |
+
|
| 162 |
+
X = np.stack(all_grids)
|
| 163 |
+
y = np.array(all_labels, dtype=np.int64)
|
| 164 |
+
return X, y
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def evaluate_on_real_binary(model, n_trials=50, max_steps=20, temperature=0.3):
|
| 168 |
+
"""Run trained model against real binary."""
|
| 169 |
+
model.eval()
|
| 170 |
+
n_achieved = 0
|
| 171 |
+
|
| 172 |
+
for trial in range(n_trials):
|
| 173 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 174 |
+
|
| 175 |
+
env = os.environ.copy()
|
| 176 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 177 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 178 |
+
|
| 179 |
+
proc = subprocess.Popen(
|
| 180 |
+
[str(BINARY)],
|
| 181 |
+
stdin=subprocess.PIPE,
|
| 182 |
+
stdout=subprocess.PIPE,
|
| 183 |
+
stderr=subprocess.PIPE,
|
| 184 |
+
env=env,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
slots_alloc = {} # slot -> True/False (allocated)
|
| 188 |
+
freed_slots = set()
|
| 189 |
+
|
| 190 |
+
for step in range(max_steps):
|
| 191 |
+
time.sleep(0.01)
|
| 192 |
+
|
| 193 |
+
# Read latest state
|
| 194 |
+
grid = np.zeros((32, 16), dtype=np.int64)
|
| 195 |
+
try:
|
| 196 |
+
with open(dump_path) as f:
|
| 197 |
+
lines = f.readlines()
|
| 198 |
+
if lines:
|
| 199 |
+
last = json.loads(lines[-1].strip())
|
| 200 |
+
grid = state_to_grid(last)
|
| 201 |
+
except Exception:
|
| 202 |
+
pass
|
| 203 |
+
|
| 204 |
+
# TRM predicts
|
| 205 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
logits = model(x)
|
| 208 |
+
if temperature == 0:
|
| 209 |
+
op = logits.argmax(1).item()
|
| 210 |
+
else:
|
| 211 |
+
probs = F.softmax(logits / temperature, dim=1)
|
| 212 |
+
op = torch.multinomial(probs, 1).item()
|
| 213 |
+
|
| 214 |
+
free_slots = [s for s in range(8) if s not in slots_alloc]
|
| 215 |
+
alloc_slots = [s for s, v in slots_alloc.items() if v]
|
| 216 |
+
|
| 217 |
+
cmd = None
|
| 218 |
+
if op == OP_MALLOC and free_slots:
|
| 219 |
+
slot = random.choice(free_slots)
|
| 220 |
+
size = random.choice(SIZES)
|
| 221 |
+
cmd = f"1 {slot} {size}"
|
| 222 |
+
slots_alloc[slot] = True
|
| 223 |
+
|
| 224 |
+
elif op == OP_FREE and alloc_slots:
|
| 225 |
+
slot = random.choice(alloc_slots)
|
| 226 |
+
cmd = f"4 {slot}"
|
| 227 |
+
slots_alloc[slot] = False
|
| 228 |
+
freed_slots.add(slot)
|
| 229 |
+
|
| 230 |
+
elif op == OP_WRITE_FREED and freed_slots:
|
| 231 |
+
slot = random.choice(list(freed_slots))
|
| 232 |
+
cmd = f"2 {slot} {'41' * 8}"
|
| 233 |
+
|
| 234 |
+
if cmd:
|
| 235 |
+
proc.stdin.write((cmd + "\n").encode())
|
| 236 |
+
proc.stdin.flush()
|
| 237 |
+
|
| 238 |
+
# Exit
|
| 239 |
+
try:
|
| 240 |
+
proc.stdin.write(b"5\n")
|
| 241 |
+
proc.stdin.flush()
|
| 242 |
+
proc.wait(timeout=2)
|
| 243 |
+
except Exception:
|
| 244 |
+
proc.kill()
|
| 245 |
+
|
| 246 |
+
# Check final dump for exploit indicators
|
| 247 |
+
try:
|
| 248 |
+
with open(dump_path) as f:
|
| 249 |
+
lines = f.readlines()
|
| 250 |
+
# Check if any freed chunk's fd was modified (tcache poison)
|
| 251 |
+
for line in lines[-5:]:
|
| 252 |
+
state = json.loads(line.strip())
|
| 253 |
+
for chunk in state.get("chunks", []):
|
| 254 |
+
if chunk.get("state") == 2 and chunk.get("fd", 0) != 0:
|
| 255 |
+
# Freed chunk with non-zero fd that we wrote
|
| 256 |
+
fd_val = chunk.get("fd", 0)
|
| 257 |
+
if fd_val == 0x4141414141414141:
|
| 258 |
+
n_achieved += 1
|
| 259 |
+
break
|
| 260 |
+
else:
|
| 261 |
+
continue
|
| 262 |
+
break
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
if os.path.exists(dump_path):
|
| 267 |
+
os.unlink(dump_path)
|
| 268 |
+
|
| 269 |
+
return n_achieved
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def main():
|
| 273 |
+
print("=== Collecting training data from real binary ===")
|
| 274 |
+
X, y = collect_training_data(n_exploit=300, n_benign=150)
|
| 275 |
+
print(f"Data: {len(X)} samples")
|
| 276 |
+
print(f"Op dist: malloc={sum(y==0)}, free={sum(y==1)}, "
|
| 277 |
+
f"write_freed={sum(y==2)}")
|
| 278 |
+
|
| 279 |
+
print("\n=== Training on real binary grids ===")
|
| 280 |
+
model = SimpleHeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 281 |
+
|
| 282 |
+
from simple_agent import train
|
| 283 |
+
train(model, X, y, epochs=100, lr=1e-3)
|
| 284 |
+
|
| 285 |
+
print("\n=== Evaluating on real binary (greedy) ===")
|
| 286 |
+
n = evaluate_on_real_binary(model, n_trials=50, temperature=0.3)
|
| 287 |
+
print(f"Achieved UAF write: {n}/50 ({n*2}%)")
|
| 288 |
+
|
| 289 |
+
print("\n=== Evaluating on real binary (best-of-5) ===")
|
| 290 |
+
n_bo5 = 0
|
| 291 |
+
for trial in range(20):
|
| 292 |
+
found = False
|
| 293 |
+
for attempt in range(5):
|
| 294 |
+
n = evaluate_on_real_binary(model, n_trials=1, temperature=0.5)
|
| 295 |
+
if n > 0:
|
| 296 |
+
found = True
|
| 297 |
+
break
|
| 298 |
+
if found:
|
| 299 |
+
n_bo5 += 1
|
| 300 |
+
print(f"Best-of-5: {n_bo5}/20 ({n_bo5*5}%)")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
if __name__ == "__main__":
|
| 304 |
+
main()
|
agent/train_universal.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
train_universal.py - Train with allocator-agnostic grid on GPU.
|
| 4 |
+
|
| 5 |
+
Uses UniversalGridEncoder (relationship-based features) and trains on RTX 4090.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import subprocess
|
| 12 |
+
import tempfile
|
| 13 |
+
import time
|
| 14 |
+
import random
|
| 15 |
+
import re
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from collections import deque
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
sys.path.insert(0, str(ROOT / "agent"))
|
| 24 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 25 |
+
|
| 26 |
+
from simple_agent import SimpleHeapTRM, OP_MALLOC, OP_FREE, OP_WRITE_FREED, SIZES
|
| 27 |
+
from universal_grid import UniversalGridEncoder
|
| 28 |
+
|
| 29 |
+
BINARY = ROOT / "ctf" / "vuln_heap"
|
| 30 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 31 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def run_and_dump(commands):
|
| 35 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 36 |
+
env = os.environ.copy()
|
| 37 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 38 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 39 |
+
input_str = "\n".join(commands) + "\n5\n"
|
| 40 |
+
subprocess.run([str(BINARY)], input=input_str.encode(),
|
| 41 |
+
env=env, capture_output=True, timeout=10)
|
| 42 |
+
states = []
|
| 43 |
+
if os.path.exists(dump_path):
|
| 44 |
+
with open(dump_path) as f:
|
| 45 |
+
for line in f:
|
| 46 |
+
if line.strip():
|
| 47 |
+
states.append(json.loads(line.strip()))
|
| 48 |
+
os.unlink(dump_path)
|
| 49 |
+
return states
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def gen_exploit(n_pre=None):
|
| 53 |
+
size = random.choice(SIZES)
|
| 54 |
+
slots = random.sample(range(16), 8)
|
| 55 |
+
if n_pre is None:
|
| 56 |
+
n_pre = random.randint(2, 6)
|
| 57 |
+
commands = []
|
| 58 |
+
labels = []
|
| 59 |
+
alloc_slots = []
|
| 60 |
+
for i in range(n_pre):
|
| 61 |
+
commands.append(f"1 {slots[i]} {size}")
|
| 62 |
+
labels.append(OP_MALLOC)
|
| 63 |
+
alloc_slots.append(slots[i])
|
| 64 |
+
free_targets = random.sample(alloc_slots, 2)
|
| 65 |
+
for s in free_targets:
|
| 66 |
+
commands.append(f"4 {s}")
|
| 67 |
+
labels.append(OP_FREE)
|
| 68 |
+
commands.append(f"2 {free_targets[1]} {'41' * 8}")
|
| 69 |
+
labels.append(OP_WRITE_FREED)
|
| 70 |
+
drain = [s for s in slots if s not in alloc_slots][:2]
|
| 71 |
+
for s in drain:
|
| 72 |
+
commands.append(f"1 {s} {size}")
|
| 73 |
+
labels.append(OP_MALLOC)
|
| 74 |
+
return commands, labels
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def gen_benign():
|
| 78 |
+
n_ops = random.randint(4, 15)
|
| 79 |
+
commands, labels = [], []
|
| 80 |
+
allocated = {}
|
| 81 |
+
for _ in range(n_ops):
|
| 82 |
+
if not allocated or random.random() < 0.55:
|
| 83 |
+
free_slots = [s for s in range(16) if s not in allocated]
|
| 84 |
+
if not free_slots:
|
| 85 |
+
break
|
| 86 |
+
s = random.choice(free_slots)
|
| 87 |
+
sz = random.choice(SIZES)
|
| 88 |
+
commands.append(f"1 {s} {sz}")
|
| 89 |
+
labels.append(OP_MALLOC)
|
| 90 |
+
allocated[s] = sz
|
| 91 |
+
else:
|
| 92 |
+
s = random.choice(list(allocated.keys()))
|
| 93 |
+
commands.append(f"4 {s}")
|
| 94 |
+
labels.append(OP_FREE)
|
| 95 |
+
del allocated[s]
|
| 96 |
+
return commands, labels
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def collect_data(n_exploit=500, n_benign=250):
|
| 100 |
+
all_grids, all_labels = [], []
|
| 101 |
+
|
| 102 |
+
print(f" Generating {n_exploit} exploit sequences...")
|
| 103 |
+
for i in range(n_exploit):
|
| 104 |
+
commands, op_labels = gen_exploit()
|
| 105 |
+
states = run_and_dump(commands)
|
| 106 |
+
if len(states) < 3:
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
encoder = UniversalGridEncoder()
|
| 110 |
+
n_free_seen = 0
|
| 111 |
+
write_done = False
|
| 112 |
+
|
| 113 |
+
for j, state in enumerate(states):
|
| 114 |
+
op = state.get("operation", "malloc")
|
| 115 |
+
if "free" in op.lower():
|
| 116 |
+
n_free_seen += 1
|
| 117 |
+
|
| 118 |
+
chunks = state.get("chunks", [])
|
| 119 |
+
n_alloc = sum(1 for c in chunks if c.get("state") == 1)
|
| 120 |
+
n_freed = sum(1 for c in chunks if c.get("state") == 2)
|
| 121 |
+
|
| 122 |
+
if n_free_seen == 0:
|
| 123 |
+
next_label = OP_FREE if (n_alloc >= 2 and j >= len(states) * 0.3) else OP_MALLOC
|
| 124 |
+
elif n_free_seen >= 2 and not write_done:
|
| 125 |
+
next_label = OP_WRITE_FREED
|
| 126 |
+
write_done = True
|
| 127 |
+
else:
|
| 128 |
+
next_label = OP_MALLOC
|
| 129 |
+
|
| 130 |
+
grid = encoder.encode(state)
|
| 131 |
+
all_grids.append(grid)
|
| 132 |
+
all_labels.append(next_label)
|
| 133 |
+
|
| 134 |
+
actual_op = OP_FREE if "free" in op.lower() else OP_MALLOC
|
| 135 |
+
encoder.record_action(actual_op, state.get("target_size", 0))
|
| 136 |
+
|
| 137 |
+
print(f" Generating {n_benign} benign sequences...")
|
| 138 |
+
for i in range(n_benign):
|
| 139 |
+
commands, _ = gen_benign()
|
| 140 |
+
states = run_and_dump(commands)
|
| 141 |
+
encoder = UniversalGridEncoder()
|
| 142 |
+
for j, state in enumerate(states):
|
| 143 |
+
op = state.get("operation", "malloc")
|
| 144 |
+
if j + 1 < len(states):
|
| 145 |
+
next_op = states[j + 1].get("operation", "malloc")
|
| 146 |
+
next_label = OP_FREE if "free" in next_op.lower() else OP_MALLOC
|
| 147 |
+
else:
|
| 148 |
+
next_label = OP_MALLOC
|
| 149 |
+
grid = encoder.encode(state)
|
| 150 |
+
all_grids.append(grid)
|
| 151 |
+
all_labels.append(next_label)
|
| 152 |
+
actual_op = OP_FREE if "free" in op.lower() else OP_MALLOC
|
| 153 |
+
encoder.record_action(actual_op, state.get("target_size", 0))
|
| 154 |
+
|
| 155 |
+
return np.stack(all_grids), np.array(all_labels, dtype=np.int64)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def train_gpu(model, X, y, epochs=200, lr=1e-3, bs=128):
|
| 159 |
+
model.to(DEVICE)
|
| 160 |
+
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 161 |
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
|
| 162 |
+
X_t = torch.from_numpy(X).long().to(DEVICE)
|
| 163 |
+
y_t = torch.from_numpy(y).long().to(DEVICE)
|
| 164 |
+
n = len(X_t)
|
| 165 |
+
|
| 166 |
+
for ep in range(1, epochs + 1):
|
| 167 |
+
model.train()
|
| 168 |
+
perm = torch.randperm(n, device=DEVICE)
|
| 169 |
+
total_loss = 0
|
| 170 |
+
correct = 0
|
| 171 |
+
nb = 0
|
| 172 |
+
for i in range(0, n, bs):
|
| 173 |
+
idx = perm[i:i+bs]
|
| 174 |
+
logits = model(X_t[idx])
|
| 175 |
+
loss = F.cross_entropy(logits, y_t[idx])
|
| 176 |
+
opt.zero_grad()
|
| 177 |
+
loss.backward()
|
| 178 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 179 |
+
opt.step()
|
| 180 |
+
total_loss += loss.item()
|
| 181 |
+
correct += (logits.argmax(1) == y_t[idx]).sum().item()
|
| 182 |
+
nb += 1
|
| 183 |
+
sched.step()
|
| 184 |
+
if ep % 20 == 0 or ep == 1:
|
| 185 |
+
print(f" Epoch {ep:3d} | loss={total_loss/nb:.4f} | acc={correct/n:.3f}")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def evaluate_live(model, n_trials=50, max_steps=20, temperature=0.3):
|
| 189 |
+
model.eval()
|
| 190 |
+
model.to(DEVICE)
|
| 191 |
+
n_uaf = 0
|
| 192 |
+
n_correct_seq = 0
|
| 193 |
+
seqs = []
|
| 194 |
+
|
| 195 |
+
for trial in range(n_trials):
|
| 196 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 197 |
+
env = os.environ.copy()
|
| 198 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 199 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 200 |
+
proc = subprocess.Popen([str(BINARY)], stdin=subprocess.PIPE,
|
| 201 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
| 202 |
+
|
| 203 |
+
encoder = UniversalGridEncoder()
|
| 204 |
+
slots = {}
|
| 205 |
+
freed = set()
|
| 206 |
+
ops = []
|
| 207 |
+
did_write = False
|
| 208 |
+
n_frees_since_alloc = 0
|
| 209 |
+
|
| 210 |
+
for step in range(max_steps):
|
| 211 |
+
time.sleep(0.01)
|
| 212 |
+
state = {"chunks": []}
|
| 213 |
+
try:
|
| 214 |
+
with open(dump_path) as f:
|
| 215 |
+
lines = f.readlines()
|
| 216 |
+
if lines:
|
| 217 |
+
state = json.loads(lines[-1].strip())
|
| 218 |
+
except:
|
| 219 |
+
pass
|
| 220 |
+
|
| 221 |
+
grid = encoder.encode(state)
|
| 222 |
+
|
| 223 |
+
# Hybrid: rule triggers W after 2+ frees
|
| 224 |
+
if freed and not did_write and n_frees_since_alloc >= 2:
|
| 225 |
+
op = OP_WRITE_FREED
|
| 226 |
+
else:
|
| 227 |
+
x = torch.from_numpy(grid).long().unsqueeze(0).to(DEVICE)
|
| 228 |
+
with torch.no_grad():
|
| 229 |
+
logits = model(x)
|
| 230 |
+
if temperature == 0:
|
| 231 |
+
op = logits.argmax(1).item()
|
| 232 |
+
else:
|
| 233 |
+
probs = F.softmax(logits / temperature, dim=1)
|
| 234 |
+
op = torch.multinomial(probs, 1).item()
|
| 235 |
+
|
| 236 |
+
free_slots = [s for s in range(8) if s not in slots]
|
| 237 |
+
alloc_slots = [s for s, v in slots.items() if v and s not in freed]
|
| 238 |
+
|
| 239 |
+
cmd = None
|
| 240 |
+
size = 0
|
| 241 |
+
if op == OP_MALLOC and free_slots:
|
| 242 |
+
s = random.choice(free_slots)
|
| 243 |
+
size = random.choice(SIZES)
|
| 244 |
+
cmd = f"1 {s} {size}"
|
| 245 |
+
slots[s] = True
|
| 246 |
+
ops.append("M")
|
| 247 |
+
n_frees_since_alloc = 0
|
| 248 |
+
elif op == OP_FREE and alloc_slots:
|
| 249 |
+
s = random.choice(alloc_slots)
|
| 250 |
+
cmd = f"4 {s}"
|
| 251 |
+
slots[s] = False
|
| 252 |
+
freed.add(s)
|
| 253 |
+
ops.append("F")
|
| 254 |
+
n_frees_since_alloc += 1
|
| 255 |
+
elif op == OP_WRITE_FREED and freed:
|
| 256 |
+
s = random.choice(list(freed))
|
| 257 |
+
cmd = f"2 {s} {'41' * 8}"
|
| 258 |
+
did_write = True
|
| 259 |
+
ops.append("W")
|
| 260 |
+
else:
|
| 261 |
+
ops.append("x")
|
| 262 |
+
|
| 263 |
+
if cmd:
|
| 264 |
+
proc.stdin.write((cmd + "\n").encode())
|
| 265 |
+
proc.stdin.flush()
|
| 266 |
+
encoder.record_action(op, size)
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
proc.stdin.write(b"5\n"); proc.stdin.flush()
|
| 270 |
+
proc.wait(timeout=2)
|
| 271 |
+
except:
|
| 272 |
+
proc.kill()
|
| 273 |
+
|
| 274 |
+
if did_write:
|
| 275 |
+
try:
|
| 276 |
+
with open(dump_path) as f:
|
| 277 |
+
for line in f:
|
| 278 |
+
state = json.loads(line.strip())
|
| 279 |
+
for c in state.get("chunks", []):
|
| 280 |
+
if c.get("fd", 0) == 0x4141414141414141:
|
| 281 |
+
n_uaf += 1
|
| 282 |
+
break
|
| 283 |
+
else:
|
| 284 |
+
continue
|
| 285 |
+
break
|
| 286 |
+
except:
|
| 287 |
+
pass
|
| 288 |
+
|
| 289 |
+
seq = "".join(ops)
|
| 290 |
+
if re.match(r"M+F+WM*", seq.replace("x", "")):
|
| 291 |
+
n_correct_seq += 1
|
| 292 |
+
seqs.append(seq)
|
| 293 |
+
if os.path.exists(dump_path):
|
| 294 |
+
os.unlink(dump_path)
|
| 295 |
+
|
| 296 |
+
return n_uaf, n_correct_seq, seqs
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def main():
|
| 300 |
+
print(f"Device: {DEVICE}")
|
| 301 |
+
|
| 302 |
+
print("\n=== Collecting data ===")
|
| 303 |
+
X, y = collect_data(n_exploit=500, n_benign=250)
|
| 304 |
+
print(f"Data: {len(X)} samples | M={sum(y==0)} F={sum(y==1)} W={sum(y==2)}")
|
| 305 |
+
|
| 306 |
+
print("\n=== Training on GPU ===")
|
| 307 |
+
model = SimpleHeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 308 |
+
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
|
| 309 |
+
train_gpu(model, X, y, epochs=300, lr=1e-3, bs=128)
|
| 310 |
+
|
| 311 |
+
print("\n=== Live evaluation (hybrid, 50 trials) ===")
|
| 312 |
+
n_uaf, n_seq, seqs = evaluate_live(model, n_trials=50, temperature=0.3)
|
| 313 |
+
print(f"UAF writes: {n_uaf}/50 ({n_uaf*2}%)")
|
| 314 |
+
print(f"Correct sequences: {n_seq}/50 ({n_seq*2}%)")
|
| 315 |
+
print("Samples:")
|
| 316 |
+
for s in seqs[:10]:
|
| 317 |
+
print(f" {s}")
|
| 318 |
+
|
| 319 |
+
print("\n=== Best-of-5 (30 trials) ===")
|
| 320 |
+
n_bo5 = 0
|
| 321 |
+
for trial in range(30):
|
| 322 |
+
found = False
|
| 323 |
+
for attempt in range(5):
|
| 324 |
+
nw, _, _ = evaluate_live(model, n_trials=1, temperature=0.5)
|
| 325 |
+
if nw > 0:
|
| 326 |
+
found = True
|
| 327 |
+
break
|
| 328 |
+
if found:
|
| 329 |
+
n_bo5 += 1
|
| 330 |
+
print(f"Best-of-5: {n_bo5}/30 ({n_bo5/30*100:.0f}%)")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
if __name__ == "__main__":
|
| 334 |
+
main()
|
agent/train_with_demos.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
train_with_demos.py - Train HeapPolicyTRM using expert demonstrations + self-play.
|
| 4 |
+
|
| 5 |
+
Phase 1: Imitation learning on known exploit sequences (how2heap-style)
|
| 6 |
+
Phase 2: Fine-tune with REINFORCE self-play
|
| 7 |
+
|
| 8 |
+
This bootstraps the policy so it doesn't have to discover exploits from scratch.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
import numpy as np
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 18 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "simulator"))
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "model"))
|
| 20 |
+
|
| 21 |
+
from heap_sim import HeapSimulator
|
| 22 |
+
from policy import HeapPolicyTRM, encode_action, decode_action, TOTAL_ACTIONS
|
| 23 |
+
from search import (
|
| 24 |
+
train_selfplay, beam_search, execute_action,
|
| 25 |
+
get_valid_actions, generate_episode,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ============================================================
|
| 30 |
+
# EXPERT DEMONSTRATIONS
|
| 31 |
+
# ============================================================
|
| 32 |
+
|
| 33 |
+
def demo_tcache_poison() -> list:
|
| 34 |
+
"""Expert sequence for tcache poisoning via UAF."""
|
| 35 |
+
return [
|
| 36 |
+
{"op": "malloc", "size": 0x40, "slot": 0},
|
| 37 |
+
{"op": "malloc", "size": 0x40, "slot": 1},
|
| 38 |
+
{"op": "malloc", "size": 0x40, "slot": 2}, # guard
|
| 39 |
+
{"op": "free", "slot": 0},
|
| 40 |
+
{"op": "free", "slot": 1},
|
| 41 |
+
{"op": "write_freed", "slot": 1, "target_slot": 2}, # poison fd
|
| 42 |
+
{"op": "malloc", "size": 0x40, "slot": 3}, # gets slot 1's chunk
|
| 43 |
+
{"op": "malloc", "size": 0x40, "slot": 4}, # gets poisoned addr
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def demo_tcache_poison_v2() -> list:
|
| 48 |
+
"""Variant with different sizes."""
|
| 49 |
+
return [
|
| 50 |
+
{"op": "malloc", "size": 0x30, "slot": 0},
|
| 51 |
+
{"op": "malloc", "size": 0x30, "slot": 1},
|
| 52 |
+
{"op": "malloc", "size": 0x30, "slot": 2},
|
| 53 |
+
{"op": "free", "slot": 1},
|
| 54 |
+
{"op": "free", "slot": 0},
|
| 55 |
+
{"op": "write_freed", "slot": 0, "target_slot": 2},
|
| 56 |
+
{"op": "malloc", "size": 0x30, "slot": 3},
|
| 57 |
+
{"op": "malloc", "size": 0x30, "slot": 4},
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def demo_tcache_poison_v3() -> list:
|
| 62 |
+
"""Variant with 0x50 size."""
|
| 63 |
+
return [
|
| 64 |
+
{"op": "malloc", "size": 0x50, "slot": 0},
|
| 65 |
+
{"op": "malloc", "size": 0x50, "slot": 1},
|
| 66 |
+
{"op": "free", "slot": 0},
|
| 67 |
+
{"op": "free", "slot": 1},
|
| 68 |
+
{"op": "write_freed", "slot": 1, "target_slot": 0},
|
| 69 |
+
{"op": "malloc", "size": 0x50, "slot": 2},
|
| 70 |
+
{"op": "malloc", "size": 0x50, "slot": 3},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def demo_double_free() -> list:
|
| 75 |
+
"""Double free via UAF (clear tcache key then re-free)."""
|
| 76 |
+
return [
|
| 77 |
+
{"op": "malloc", "size": 0x40, "slot": 0},
|
| 78 |
+
{"op": "malloc", "size": 0x40, "slot": 1},
|
| 79 |
+
{"op": "free", "slot": 0},
|
| 80 |
+
{"op": "free", "slot": 1},
|
| 81 |
+
# In a real exploit, we'd clear tcache key via UAF write
|
| 82 |
+
# For the simulator, we can directly double-free since we
|
| 83 |
+
# don't model the key check (pre-2.29 behavior)
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def generate_demo_variants(base_demo, n_variants=20) -> list:
|
| 88 |
+
"""Generate variants of a demo with different sizes and slot assignments."""
|
| 89 |
+
import random
|
| 90 |
+
variants = [base_demo()]
|
| 91 |
+
|
| 92 |
+
sizes = [0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80]
|
| 93 |
+
|
| 94 |
+
for _ in range(n_variants):
|
| 95 |
+
demo = base_demo()
|
| 96 |
+
# Randomly remap sizes
|
| 97 |
+
size = random.choice(sizes)
|
| 98 |
+
for step in demo:
|
| 99 |
+
if "size" in step:
|
| 100 |
+
step["size"] = size
|
| 101 |
+
|
| 102 |
+
# Randomly remap slots (preserving relationships)
|
| 103 |
+
slot_map = {}
|
| 104 |
+
available = list(range(8))
|
| 105 |
+
random.shuffle(available)
|
| 106 |
+
for step in demo:
|
| 107 |
+
for key in ["slot", "target_slot"]:
|
| 108 |
+
if key in step:
|
| 109 |
+
old = step[key]
|
| 110 |
+
if old not in slot_map:
|
| 111 |
+
slot_map[old] = available.pop(0)
|
| 112 |
+
step[key] = slot_map[old]
|
| 113 |
+
|
| 114 |
+
variants.append(demo)
|
| 115 |
+
|
| 116 |
+
return variants
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ============================================================
|
| 120 |
+
# IMITATION LEARNING
|
| 121 |
+
# ============================================================
|
| 122 |
+
|
| 123 |
+
def collect_demo_data(demos: list) -> tuple:
|
| 124 |
+
"""Run demos through simulator, collect (state, action) pairs."""
|
| 125 |
+
states = []
|
| 126 |
+
actions = []
|
| 127 |
+
rewards = []
|
| 128 |
+
|
| 129 |
+
for demo in demos:
|
| 130 |
+
sim = HeapSimulator()
|
| 131 |
+
for i, step in enumerate(demo):
|
| 132 |
+
grid = sim.state_to_grid()
|
| 133 |
+
action_idx = encode_action(**step)
|
| 134 |
+
|
| 135 |
+
states.append(grid)
|
| 136 |
+
actions.append(action_idx)
|
| 137 |
+
|
| 138 |
+
success = execute_action(sim, action_idx)
|
| 139 |
+
|
| 140 |
+
# Check if we achieved anything
|
| 141 |
+
prims = sim.check_primitives()
|
| 142 |
+
if any(prims.values()):
|
| 143 |
+
rewards.append(1.0)
|
| 144 |
+
else:
|
| 145 |
+
rewards.append(0.0)
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
np.stack(states),
|
| 149 |
+
np.array(actions, dtype=np.int64),
|
| 150 |
+
np.array(rewards, dtype=np.float32),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def train_imitation(
|
| 155 |
+
model: HeapPolicyTRM,
|
| 156 |
+
states: np.ndarray,
|
| 157 |
+
actions: np.ndarray,
|
| 158 |
+
epochs: int = 100,
|
| 159 |
+
batch_size: int = 32,
|
| 160 |
+
lr: float = 1e-3,
|
| 161 |
+
) -> float:
|
| 162 |
+
"""Train policy via behavioral cloning on expert demos."""
|
| 163 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 164 |
+
|
| 165 |
+
X = torch.from_numpy(states).long()
|
| 166 |
+
y = torch.from_numpy(actions).long()
|
| 167 |
+
n = len(X)
|
| 168 |
+
|
| 169 |
+
model.train()
|
| 170 |
+
best_acc = 0.0
|
| 171 |
+
|
| 172 |
+
for epoch in range(1, epochs + 1):
|
| 173 |
+
perm = torch.randperm(n)
|
| 174 |
+
total_loss = 0.0
|
| 175 |
+
correct = 0
|
| 176 |
+
n_batches = 0
|
| 177 |
+
|
| 178 |
+
for i in range(0, n, batch_size):
|
| 179 |
+
idx = perm[i:i+batch_size]
|
| 180 |
+
x_batch = X[idx]
|
| 181 |
+
y_batch = y[idx]
|
| 182 |
+
|
| 183 |
+
logits, value = model(x_batch)
|
| 184 |
+
loss = F.cross_entropy(logits, y_batch)
|
| 185 |
+
|
| 186 |
+
optimizer.zero_grad()
|
| 187 |
+
loss.backward()
|
| 188 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 189 |
+
optimizer.step()
|
| 190 |
+
|
| 191 |
+
total_loss += loss.item()
|
| 192 |
+
correct += (logits.argmax(dim=1) == y_batch).sum().item()
|
| 193 |
+
n_batches += 1
|
| 194 |
+
|
| 195 |
+
acc = correct / n
|
| 196 |
+
avg_loss = total_loss / n_batches
|
| 197 |
+
if acc > best_acc:
|
| 198 |
+
best_acc = acc
|
| 199 |
+
|
| 200 |
+
if epoch % 10 == 0 or epoch == 1:
|
| 201 |
+
print(f" Epoch {epoch:3d} | loss={avg_loss:.4f} | acc={acc:.3f} "
|
| 202 |
+
f"| best_acc={best_acc:.3f}")
|
| 203 |
+
|
| 204 |
+
return best_acc
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ============================================================
|
| 208 |
+
# MAIN
|
| 209 |
+
# ============================================================
|
| 210 |
+
|
| 211 |
+
def main():
|
| 212 |
+
print("=== Phase 1: Generating expert demonstrations ===")
|
| 213 |
+
all_demos = []
|
| 214 |
+
all_demos += generate_demo_variants(demo_tcache_poison, 30)
|
| 215 |
+
all_demos += generate_demo_variants(demo_tcache_poison_v2, 30)
|
| 216 |
+
all_demos += generate_demo_variants(demo_tcache_poison_v3, 30)
|
| 217 |
+
print(f"Generated {len(all_demos)} demo sequences")
|
| 218 |
+
|
| 219 |
+
states, actions, rewards = collect_demo_data(all_demos)
|
| 220 |
+
print(f"Collected {len(states)} (state, action) pairs")
|
| 221 |
+
print(f"Achieved exploit in {(rewards > 0).sum()} steps")
|
| 222 |
+
|
| 223 |
+
print("\n=== Phase 2: Imitation learning ===")
|
| 224 |
+
model = HeapPolicyTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 225 |
+
print(f"Parameters: {sum(p.numel() for p in model.parameters()):,}")
|
| 226 |
+
|
| 227 |
+
best_acc = train_imitation(model, states, actions, epochs=100, lr=1e-3)
|
| 228 |
+
print(f"Best imitation accuracy: {best_acc:.3f}")
|
| 229 |
+
|
| 230 |
+
# Test: can the model reproduce exploits?
|
| 231 |
+
print("\n=== Phase 3: Testing learned policy ===")
|
| 232 |
+
n_test = 50
|
| 233 |
+
n_achieved = 0
|
| 234 |
+
lengths = []
|
| 235 |
+
|
| 236 |
+
for i in range(n_test):
|
| 237 |
+
episode = generate_episode(model, goal="tcache_poison",
|
| 238 |
+
max_steps=20, temperature=0.3)
|
| 239 |
+
if episode["achieved"]:
|
| 240 |
+
n_achieved += 1
|
| 241 |
+
lengths.append(episode["n_steps"])
|
| 242 |
+
|
| 243 |
+
print(f"Policy achieves tcache_poison: {n_achieved}/{n_test} "
|
| 244 |
+
f"({n_achieved/n_test*100:.0f}%)")
|
| 245 |
+
if lengths:
|
| 246 |
+
print(f"Average steps when successful: {np.mean(lengths):.1f}")
|
| 247 |
+
|
| 248 |
+
# Phase 4: Beam search
|
| 249 |
+
print("\n=== Phase 4: Beam search ===")
|
| 250 |
+
sim = HeapSimulator()
|
| 251 |
+
result = beam_search(model, sim, goal="tcache_poison",
|
| 252 |
+
beam_width=16, max_steps=20, temperature=0.3)
|
| 253 |
+
|
| 254 |
+
if result and result.primitives.get("tcache_poison"):
|
| 255 |
+
print(f"Beam search found exploit in {len(result.actions)} steps:")
|
| 256 |
+
for i, a in enumerate(result.actions):
|
| 257 |
+
print(f" Step {i}: {decode_action(a)}")
|
| 258 |
+
else:
|
| 259 |
+
print("Beam search did not find exploit")
|
| 260 |
+
|
| 261 |
+
# Phase 5: Fine-tune with self-play
|
| 262 |
+
print("\n=== Phase 5: Self-play fine-tuning ===")
|
| 263 |
+
stats = train_selfplay(
|
| 264 |
+
model,
|
| 265 |
+
goal="tcache_poison",
|
| 266 |
+
n_episodes=200,
|
| 267 |
+
max_steps=20,
|
| 268 |
+
lr=1e-4,
|
| 269 |
+
print_every=50,
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Final test
|
| 273 |
+
print("\n=== Final evaluation ===")
|
| 274 |
+
n_achieved = 0
|
| 275 |
+
for i in range(100):
|
| 276 |
+
episode = generate_episode(model, goal="tcache_poison",
|
| 277 |
+
max_steps=20, temperature=0.1)
|
| 278 |
+
if episode["achieved"]:
|
| 279 |
+
n_achieved += 1
|
| 280 |
+
|
| 281 |
+
print(f"Final success rate: {n_achieved}/100 ({n_achieved}%)")
|
| 282 |
+
|
| 283 |
+
# Save model
|
| 284 |
+
output_dir = Path(__file__).parent.parent / "agent" / "checkpoints"
|
| 285 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 286 |
+
torch.save(model.state_dict(), output_dir / "policy_model.pt")
|
| 287 |
+
print(f"Model saved to {output_dir / 'policy_model.pt'}")
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
if __name__ == "__main__":
|
| 291 |
+
main()
|
agent/universal_grid.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
universal_grid.py - Allocator-agnostic grid encoding.
|
| 3 |
+
|
| 4 |
+
Encodes RELATIONSHIPS between chunks rather than allocator internals.
|
| 5 |
+
This should generalize across ptmalloc2, jemalloc, tcmalloc, etc.
|
| 6 |
+
|
| 7 |
+
Grid: 32 rows x 16 cols
|
| 8 |
+
Rows 0-23: Chunks (sorted by address)
|
| 9 |
+
Rows 24-27: Action history (last 4 ops)
|
| 10 |
+
Rows 28-31: Summary statistics
|
| 11 |
+
|
| 12 |
+
Column layout (per chunk row):
|
| 13 |
+
0: state (0=pad, 1=allocated, 2=freed)
|
| 14 |
+
1: size_bucket (size quantized to 0-63 buckets)
|
| 15 |
+
2: same_size_alloc (count of other allocated chunks with same size)
|
| 16 |
+
3: same_size_freed (count of other freed chunks with same size)
|
| 17 |
+
4: prev_chunk_free (1 if previous adjacent chunk is free)
|
| 18 |
+
5: next_chunk_free (1 if next adjacent chunk is free)
|
| 19 |
+
6: can_coalesce (1 if freeing this would merge with a neighbor)
|
| 20 |
+
7: fwd_ptr_target (0=null, 1-24=chunk index, 33=external)
|
| 21 |
+
8: fwd_ptr_corrupted (1 if fd doesn't point to expected freelist member)
|
| 22 |
+
9: is_adjacent_to_freed (1 if directly next to a freed chunk)
|
| 23 |
+
10: position_in_heap (0=bottom, 63=top, relative position)
|
| 24 |
+
11: distance_to_same_size_free (0=none, 1-63 chunks away)
|
| 25 |
+
12: n_times_reallocated (alloc_order reuse indicator)
|
| 26 |
+
13: was_target_of_last_op (1/0)
|
| 27 |
+
14: chunk_isolation (count of allocated neighbors within 3 slots)
|
| 28 |
+
15: size_uniqueness (1 if this is the only chunk of this size, 0 otherwise)
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
from typing import List, Dict
|
| 33 |
+
from collections import Counter, deque
|
| 34 |
+
|
| 35 |
+
GRID_ROWS = 32
|
| 36 |
+
GRID_COLS = 16
|
| 37 |
+
CHUNK_ROWS = 24
|
| 38 |
+
HISTORY_ROWS = 4
|
| 39 |
+
SUMMARY_ROWS = 4
|
| 40 |
+
VOCAB = 64
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def clamp(v, lo=0, hi=63):
|
| 44 |
+
return max(lo, min(hi, int(v)))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def size_bucket(size: int) -> int:
|
| 48 |
+
"""Quantize chunk size to bucket 0-63. Logarithmic-ish."""
|
| 49 |
+
if size <= 0:
|
| 50 |
+
return 0
|
| 51 |
+
if size <= 0x80:
|
| 52 |
+
return clamp(size >> 4) # 0x10 steps -> 0-8
|
| 53 |
+
if size <= 0x400:
|
| 54 |
+
return clamp(8 + (size - 0x80) >> 5) # coarser
|
| 55 |
+
return clamp(32 + (size - 0x400) >> 8)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class UniversalGridEncoder:
|
| 59 |
+
"""Allocator-agnostic grid encoder with action history."""
|
| 60 |
+
|
| 61 |
+
def __init__(self):
|
| 62 |
+
self.action_history = deque(maxlen=HISTORY_ROWS)
|
| 63 |
+
self.total_allocs = 0
|
| 64 |
+
self.total_frees = 0
|
| 65 |
+
self.total_writes = 0
|
| 66 |
+
self.step = 0
|
| 67 |
+
|
| 68 |
+
def record_action(self, op_type: int, size: int = 0):
|
| 69 |
+
self.action_history.appendleft({"op": op_type, "size": size, "step": self.step})
|
| 70 |
+
if op_type == 0:
|
| 71 |
+
self.total_allocs += 1
|
| 72 |
+
elif op_type == 1:
|
| 73 |
+
self.total_frees += 1
|
| 74 |
+
elif op_type == 2:
|
| 75 |
+
self.total_writes += 1
|
| 76 |
+
self.step += 1
|
| 77 |
+
|
| 78 |
+
def encode(self, state: dict) -> np.ndarray:
|
| 79 |
+
grid = np.zeros((GRID_ROWS, GRID_COLS), dtype=np.int64)
|
| 80 |
+
chunks = state.get("chunks", [])
|
| 81 |
+
if not chunks:
|
| 82 |
+
self._encode_history_and_summary(grid, chunks)
|
| 83 |
+
return grid
|
| 84 |
+
|
| 85 |
+
# Precompute: group by size
|
| 86 |
+
sizes = [c.get("chunk_size", 0) for c in chunks]
|
| 87 |
+
states_list = [c.get("state", 0) for c in chunks]
|
| 88 |
+
|
| 89 |
+
size_alloc_count = Counter()
|
| 90 |
+
size_freed_count = Counter()
|
| 91 |
+
for sz, st in zip(sizes, states_list):
|
| 92 |
+
sb = size_bucket(sz)
|
| 93 |
+
if st == 1:
|
| 94 |
+
size_alloc_count[sb] += 1
|
| 95 |
+
elif st == 2:
|
| 96 |
+
size_freed_count[sb] += 1
|
| 97 |
+
|
| 98 |
+
# Precompute: for each size, index of nearest freed chunk
|
| 99 |
+
freed_indices_by_size = {}
|
| 100 |
+
for i, (sz, st) in enumerate(zip(sizes, states_list)):
|
| 101 |
+
sb = size_bucket(sz)
|
| 102 |
+
if st == 2:
|
| 103 |
+
freed_indices_by_size.setdefault(sb, []).append(i)
|
| 104 |
+
|
| 105 |
+
n_chunks = len(chunks)
|
| 106 |
+
|
| 107 |
+
for i, c in enumerate(chunks[:CHUNK_ROWS]):
|
| 108 |
+
sz = c.get("chunk_size", 0)
|
| 109 |
+
sb = size_bucket(sz)
|
| 110 |
+
st = c.get("state", 0)
|
| 111 |
+
|
| 112 |
+
# Col 0: state
|
| 113 |
+
grid[i, 0] = clamp(st, 0, 2)
|
| 114 |
+
|
| 115 |
+
# Col 1: size bucket
|
| 116 |
+
grid[i, 1] = sb
|
| 117 |
+
|
| 118 |
+
# Col 2: same-size allocated count (excluding self)
|
| 119 |
+
sa = size_alloc_count.get(sb, 0)
|
| 120 |
+
if st == 1:
|
| 121 |
+
sa -= 1
|
| 122 |
+
grid[i, 2] = clamp(sa)
|
| 123 |
+
|
| 124 |
+
# Col 3: same-size freed count
|
| 125 |
+
grid[i, 3] = clamp(size_freed_count.get(sb, 0) - (1 if st == 2 else 0))
|
| 126 |
+
|
| 127 |
+
# Col 4: prev chunk free
|
| 128 |
+
if i > 0 and states_list[i - 1] == 2:
|
| 129 |
+
grid[i, 4] = 1
|
| 130 |
+
|
| 131 |
+
# Col 5: next chunk free
|
| 132 |
+
if i + 1 < n_chunks and states_list[i + 1] == 2:
|
| 133 |
+
grid[i, 5] = 1
|
| 134 |
+
|
| 135 |
+
# Col 6: can_coalesce (would merge if freed)
|
| 136 |
+
if st == 1:
|
| 137 |
+
prev_free = (i > 0 and states_list[i - 1] == 2)
|
| 138 |
+
next_free = (i + 1 < n_chunks and states_list[i + 1] == 2)
|
| 139 |
+
grid[i, 6] = 1 if (prev_free or next_free) else 0
|
| 140 |
+
|
| 141 |
+
# Col 7: forward pointer target
|
| 142 |
+
fd_idx = c.get("fd_idx", -1)
|
| 143 |
+
if fd_idx == -1:
|
| 144 |
+
grid[i, 7] = 0
|
| 145 |
+
elif fd_idx == -2:
|
| 146 |
+
grid[i, 7] = 33 # external
|
| 147 |
+
else:
|
| 148 |
+
grid[i, 7] = clamp(fd_idx + 1, 1, 32)
|
| 149 |
+
|
| 150 |
+
# Col 8: forward pointer corrupted
|
| 151 |
+
# Heuristic: if freed chunk's fd points to external or to a non-same-size chunk
|
| 152 |
+
if st == 2 and c.get("fd", 0) != 0:
|
| 153 |
+
fd_idx_val = c.get("fd_idx", -1)
|
| 154 |
+
if fd_idx_val == -2:
|
| 155 |
+
grid[i, 8] = 1 # points outside heap
|
| 156 |
+
elif fd_idx_val >= 0 and fd_idx_val < len(chunks):
|
| 157 |
+
target_sz = size_bucket(chunks[fd_idx_val].get("chunk_size", 0))
|
| 158 |
+
if target_sz != sb:
|
| 159 |
+
grid[i, 8] = 1 # points to wrong size class
|
| 160 |
+
|
| 161 |
+
# Col 9: adjacent to freed chunk
|
| 162 |
+
adj_free = (i > 0 and states_list[i - 1] == 2) or \
|
| 163 |
+
(i + 1 < n_chunks and states_list[i + 1] == 2)
|
| 164 |
+
grid[i, 9] = 1 if adj_free else 0
|
| 165 |
+
|
| 166 |
+
# Col 10: position in heap (relative)
|
| 167 |
+
grid[i, 10] = clamp(int(i / max(n_chunks - 1, 1) * 63))
|
| 168 |
+
|
| 169 |
+
# Col 11: distance to nearest same-size freed chunk
|
| 170 |
+
freed_idxs = freed_indices_by_size.get(sb, [])
|
| 171 |
+
if freed_idxs and not (st == 2):
|
| 172 |
+
min_dist = min(abs(i - fi) for fi in freed_idxs)
|
| 173 |
+
grid[i, 11] = clamp(min_dist)
|
| 174 |
+
else:
|
| 175 |
+
grid[i, 11] = 0
|
| 176 |
+
|
| 177 |
+
# Col 12: alloc order
|
| 178 |
+
grid[i, 12] = clamp(c.get("alloc_order", 0))
|
| 179 |
+
|
| 180 |
+
# Col 13: was target of last operation
|
| 181 |
+
grid[i, 13] = c.get("is_target", 0)
|
| 182 |
+
|
| 183 |
+
# Col 14: chunk isolation (allocated neighbors within 3 positions)
|
| 184 |
+
neighbors = 0
|
| 185 |
+
for di in range(-3, 4):
|
| 186 |
+
ni = i + di
|
| 187 |
+
if di != 0 and 0 <= ni < n_chunks and states_list[ni] == 1:
|
| 188 |
+
neighbors += 1
|
| 189 |
+
grid[i, 14] = clamp(neighbors)
|
| 190 |
+
|
| 191 |
+
# Col 15: size uniqueness
|
| 192 |
+
total_same = size_alloc_count.get(sb, 0) + size_freed_count.get(sb, 0)
|
| 193 |
+
grid[i, 15] = 1 if total_same <= 1 else 0
|
| 194 |
+
|
| 195 |
+
self._encode_history_and_summary(grid, chunks)
|
| 196 |
+
return grid
|
| 197 |
+
|
| 198 |
+
def _encode_history_and_summary(self, grid, chunks):
|
| 199 |
+
# History rows (24-27)
|
| 200 |
+
for i, action in enumerate(self.action_history):
|
| 201 |
+
row = CHUNK_ROWS + i
|
| 202 |
+
if row >= CHUNK_ROWS + HISTORY_ROWS:
|
| 203 |
+
break
|
| 204 |
+
grid[row, 0] = 60 # marker
|
| 205 |
+
grid[row, 1] = clamp(action["op"] + 1, 1, 5)
|
| 206 |
+
grid[row, 2] = size_bucket(action.get("size", 0))
|
| 207 |
+
grid[row, 3] = clamp(self.step - action["step"])
|
| 208 |
+
grid[row, 4] = clamp(self.total_allocs)
|
| 209 |
+
grid[row, 5] = clamp(self.total_frees)
|
| 210 |
+
grid[row, 6] = clamp(self.total_writes)
|
| 211 |
+
|
| 212 |
+
# Summary row (28)
|
| 213 |
+
sr = CHUNK_ROWS + HISTORY_ROWS
|
| 214 |
+
n_alloc = sum(1 for c in chunks if c.get("state") == 1)
|
| 215 |
+
n_freed = sum(1 for c in chunks if c.get("state") == 2)
|
| 216 |
+
n_total = len(chunks)
|
| 217 |
+
n_poisoned = sum(1 for c in chunks if c.get("state") == 2 and c.get("fd", 0) != 0)
|
| 218 |
+
|
| 219 |
+
# Count distinct size classes
|
| 220 |
+
size_set = set()
|
| 221 |
+
for c in chunks:
|
| 222 |
+
size_set.add(size_bucket(c.get("chunk_size", 0)))
|
| 223 |
+
|
| 224 |
+
grid[sr, 0] = 50 # marker
|
| 225 |
+
grid[sr, 1] = clamp(n_alloc)
|
| 226 |
+
grid[sr, 2] = clamp(n_freed)
|
| 227 |
+
grid[sr, 3] = clamp(n_total)
|
| 228 |
+
grid[sr, 4] = clamp(self.total_allocs)
|
| 229 |
+
grid[sr, 5] = clamp(self.total_frees)
|
| 230 |
+
grid[sr, 6] = clamp(self.total_writes)
|
| 231 |
+
grid[sr, 7] = clamp(self.step)
|
| 232 |
+
grid[sr, 8] = clamp(n_poisoned)
|
| 233 |
+
|
| 234 |
+
# Phase indicator
|
| 235 |
+
if n_freed == 0:
|
| 236 |
+
phase = 1 # alloc
|
| 237 |
+
elif self.total_writes == 0 and n_freed > 0:
|
| 238 |
+
phase = 2 # ready to corrupt
|
| 239 |
+
elif self.total_writes > 0:
|
| 240 |
+
phase = 3 # post-corruption
|
| 241 |
+
else:
|
| 242 |
+
phase = 0
|
| 243 |
+
grid[sr, 9] = phase
|
| 244 |
+
|
| 245 |
+
# Freed-to-alloc ratio
|
| 246 |
+
if n_total > 0:
|
| 247 |
+
grid[sr, 10] = clamp(int(n_freed / n_total * 63))
|
| 248 |
+
|
| 249 |
+
grid[sr, 11] = clamp(len(size_set))
|
| 250 |
+
|
| 251 |
+
# Coalesce opportunity count
|
| 252 |
+
coalesce_count = 0
|
| 253 |
+
states_list = [c.get("state", 0) for c in chunks]
|
| 254 |
+
for i in range(len(chunks)):
|
| 255 |
+
if states_list[i] == 1:
|
| 256 |
+
prev_free = (i > 0 and states_list[i-1] == 2)
|
| 257 |
+
next_free = (i+1 < len(chunks) and states_list[i+1] == 2)
|
| 258 |
+
if prev_free or next_free:
|
| 259 |
+
coalesce_count += 1
|
| 260 |
+
grid[sr, 12] = clamp(coalesce_count)
|
| 261 |
+
|
| 262 |
+
# Has any freelist corruption
|
| 263 |
+
grid[sr, 13] = 1 if n_poisoned > 0 else 0
|
ctf/drive_ctf.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
drive_ctf.py - Generate exploit and random interaction scripts for vuln_heap.
|
| 4 |
+
|
| 5 |
+
Produces stdin scripts that drive the CTF binary through:
|
| 6 |
+
1. Known exploit sequences (tcache poison via UAF, overlapping chunks via off-by-one)
|
| 7 |
+
2. Random benign interactions (alloc/edit/show/delete in random order)
|
| 8 |
+
3. Partial exploits (start exploit but don't finish — intermediate states)
|
| 9 |
+
|
| 10 |
+
Each script is a text file of menu commands fed via stdin.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import random
|
| 14 |
+
import os
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
OUTPUT_DIR = Path(__file__).parent / "scripts"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def cmd_alloc(idx, size):
|
| 21 |
+
return f"1 {idx} {size}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def cmd_edit(idx, data_hex):
|
| 25 |
+
return f"2 {idx} {data_hex}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def cmd_show(idx):
|
| 29 |
+
return f"3 {idx}"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def cmd_delete(idx):
|
| 33 |
+
return f"4 {idx}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def cmd_exit():
|
| 37 |
+
return "5"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ============================================================
|
| 41 |
+
# EXPLOIT 1: Tcache poisoning via UAF
|
| 42 |
+
#
|
| 43 |
+
# Steps:
|
| 44 |
+
# 1. Alloc A (idx=0, size=0x40)
|
| 45 |
+
# 2. Alloc B (idx=1, size=0x40) — prevent consolidation
|
| 46 |
+
# 3. Free A
|
| 47 |
+
# 4. Free B
|
| 48 |
+
# 5. UAF edit B: overwrite fd pointer to target (fake address)
|
| 49 |
+
# 6. Alloc C (idx=2, size=0x40) — gets B's chunk
|
| 50 |
+
# 7. Alloc D (idx=3, size=0x40) — gets controlled address!
|
| 51 |
+
# ============================================================
|
| 52 |
+
|
| 53 |
+
def exploit_tcache_poison():
|
| 54 |
+
"""Tcache poisoning via UAF double-write."""
|
| 55 |
+
cmds = []
|
| 56 |
+
# Setup: fill some notes for realism (noise)
|
| 57 |
+
cmds.append(cmd_alloc(8, 0x20)) # noise
|
| 58 |
+
cmds.append(cmd_alloc(9, 0x30)) # noise
|
| 59 |
+
|
| 60 |
+
# Core exploit
|
| 61 |
+
cmds.append(cmd_alloc(0, 0x40)) # target chunk A
|
| 62 |
+
cmds.append(cmd_alloc(1, 0x40)) # guard chunk B
|
| 63 |
+
cmds.append(cmd_alloc(2, 0x40)) # another guard
|
| 64 |
+
|
| 65 |
+
cmds.append(cmd_delete(0)) # free A -> tcache
|
| 66 |
+
cmds.append(cmd_delete(1)) # free B -> tcache head
|
| 67 |
+
|
| 68 |
+
# UAF: edit freed chunk B's fd to point to arbitrary address
|
| 69 |
+
# In a real exploit this would be __malloc_hook or __free_hook
|
| 70 |
+
# We use 0x41414141 as a marker
|
| 71 |
+
fake_fd = "4141414141414141"
|
| 72 |
+
cmds.append(cmd_edit(1, fake_fd)) # UAF write: corrupt tcache fd
|
| 73 |
+
|
| 74 |
+
cmds.append(cmd_alloc(3, 0x40)) # consumes B from tcache
|
| 75 |
+
cmds.append(cmd_alloc(4, 0x40)) # SHOULD return our fake address
|
| 76 |
+
|
| 77 |
+
# Show to "leak" — this is the exploit completing
|
| 78 |
+
cmds.append(cmd_show(4))
|
| 79 |
+
|
| 80 |
+
cmds.append(cmd_exit())
|
| 81 |
+
return cmds
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ============================================================
|
| 85 |
+
# EXPLOIT 2: Overlapping chunks via off-by-one null byte
|
| 86 |
+
#
|
| 87 |
+
# Steps:
|
| 88 |
+
# 1. Alloc A (idx=0, size=0x78) — will overflow
|
| 89 |
+
# 2. Alloc B (idx=1, size=0x78) — victim
|
| 90 |
+
# 3. Alloc C (idx=2, size=0x78) — prevent top consolidation
|
| 91 |
+
# 4. Edit A with full 0x78 bytes — null byte overflows into B's size
|
| 92 |
+
# 5. Free B — uses corrupted size
|
| 93 |
+
# 6. Alloc over the corrupted region
|
| 94 |
+
# ============================================================
|
| 95 |
+
|
| 96 |
+
def exploit_off_by_one():
|
| 97 |
+
"""Off-by-one null byte to create overlapping chunks."""
|
| 98 |
+
cmds = []
|
| 99 |
+
# Noise
|
| 100 |
+
cmds.append(cmd_alloc(10, 0x20))
|
| 101 |
+
cmds.append(cmd_alloc(11, 0x30))
|
| 102 |
+
|
| 103 |
+
# Setup
|
| 104 |
+
cmds.append(cmd_alloc(0, 0x78)) # chunk A — will overflow
|
| 105 |
+
cmds.append(cmd_alloc(1, 0x78)) # chunk B — victim
|
| 106 |
+
cmds.append(cmd_alloc(2, 0x78)) # chunk C — guard
|
| 107 |
+
|
| 108 |
+
# Fill A completely — the null terminator overflows into B's prev_inuse
|
| 109 |
+
payload = "41" * 0x78 # 0x78 bytes of 'A'
|
| 110 |
+
cmds.append(cmd_edit(0, payload)) # off-by-one: null byte into B's size LSB
|
| 111 |
+
|
| 112 |
+
# Free B with corrupted size field
|
| 113 |
+
cmds.append(cmd_delete(1))
|
| 114 |
+
|
| 115 |
+
# Alloc over the corrupted region
|
| 116 |
+
cmds.append(cmd_alloc(3, 0x78))
|
| 117 |
+
|
| 118 |
+
# Now idx 3 and a portion of idx 2 overlap
|
| 119 |
+
cmds.append(cmd_show(3))
|
| 120 |
+
|
| 121 |
+
cmds.append(cmd_exit())
|
| 122 |
+
return cmds
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ============================================================
|
| 126 |
+
# EXPLOIT 3: Double free via UAF
|
| 127 |
+
# ============================================================
|
| 128 |
+
|
| 129 |
+
def exploit_double_free():
|
| 130 |
+
"""Classic double free using UAF (pointer not cleared)."""
|
| 131 |
+
cmds = []
|
| 132 |
+
# Noise
|
| 133 |
+
cmds.append(cmd_alloc(7, 0x50))
|
| 134 |
+
cmds.append(cmd_edit(7, "deadbeef" * 4))
|
| 135 |
+
cmds.append(cmd_show(7))
|
| 136 |
+
|
| 137 |
+
# Setup
|
| 138 |
+
cmds.append(cmd_alloc(0, 0x40))
|
| 139 |
+
cmds.append(cmd_alloc(1, 0x40))
|
| 140 |
+
|
| 141 |
+
# Double free: free A, free B, free A again (tcache key bypass via edit)
|
| 142 |
+
cmds.append(cmd_delete(0))
|
| 143 |
+
# Edit freed chunk to clear tcache key
|
| 144 |
+
cmds.append(cmd_edit(0, "00" * 0x40))
|
| 145 |
+
cmds.append(cmd_delete(0)) # double free!
|
| 146 |
+
|
| 147 |
+
# Now tcache has A -> A (cycle)
|
| 148 |
+
cmds.append(cmd_alloc(2, 0x40)) # gets A
|
| 149 |
+
# Write fake fd
|
| 150 |
+
cmds.append(cmd_edit(2, "4242424242424242"))
|
| 151 |
+
cmds.append(cmd_alloc(3, 0x40)) # gets A again
|
| 152 |
+
cmds.append(cmd_alloc(4, 0x40)) # gets fake address!
|
| 153 |
+
|
| 154 |
+
cmds.append(cmd_exit())
|
| 155 |
+
return cmds
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ============================================================
|
| 159 |
+
# RANDOM BENIGN INTERACTIONS
|
| 160 |
+
# ============================================================
|
| 161 |
+
|
| 162 |
+
def random_benign(n_ops=30, seed=None):
|
| 163 |
+
"""Generate random benign heap interactions."""
|
| 164 |
+
if seed is not None:
|
| 165 |
+
random.seed(seed)
|
| 166 |
+
|
| 167 |
+
cmds = []
|
| 168 |
+
allocated = {} # idx -> size
|
| 169 |
+
|
| 170 |
+
for _ in range(n_ops):
|
| 171 |
+
action = random.choice(["alloc", "edit", "show", "delete", "alloc"]) # bias toward alloc
|
| 172 |
+
|
| 173 |
+
if action == "alloc":
|
| 174 |
+
idx = random.randint(0, 15)
|
| 175 |
+
if idx not in allocated:
|
| 176 |
+
size = random.choice([0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x78, 0x80])
|
| 177 |
+
cmds.append(cmd_alloc(idx, size))
|
| 178 |
+
allocated[idx] = size
|
| 179 |
+
|
| 180 |
+
elif action == "edit" and allocated:
|
| 181 |
+
idx = random.choice(list(allocated.keys()))
|
| 182 |
+
size = allocated[idx]
|
| 183 |
+
# Write random data, but DON'T overflow (benign)
|
| 184 |
+
data_len = random.randint(1, max(1, size - 1)) # stay within bounds
|
| 185 |
+
data = "".join(random.choice("0123456789abcdef") for _ in range(data_len * 2))
|
| 186 |
+
cmds.append(cmd_edit(idx, data))
|
| 187 |
+
|
| 188 |
+
elif action == "show" and allocated:
|
| 189 |
+
idx = random.choice(list(allocated.keys()))
|
| 190 |
+
cmds.append(cmd_show(idx))
|
| 191 |
+
|
| 192 |
+
elif action == "delete" and allocated:
|
| 193 |
+
idx = random.choice(list(allocated.keys()))
|
| 194 |
+
cmds.append(cmd_delete(idx))
|
| 195 |
+
del allocated[idx] # benign: we track deletion properly
|
| 196 |
+
|
| 197 |
+
cmds.append(cmd_exit())
|
| 198 |
+
return cmds
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ============================================================
|
| 202 |
+
# NOISY EXPLOIT: Real exploit buried in random operations
|
| 203 |
+
# ============================================================
|
| 204 |
+
|
| 205 |
+
def noisy_exploit(exploit_fn, noise_before=15, noise_between=3, seed=42):
|
| 206 |
+
"""Wrap an exploit in random noise operations to simulate real CTF solving."""
|
| 207 |
+
random.seed(seed)
|
| 208 |
+
cmds = []
|
| 209 |
+
|
| 210 |
+
# Random noise before exploit (allocating unrelated notes, editing, showing)
|
| 211 |
+
noise_allocated = {}
|
| 212 |
+
for _ in range(noise_before):
|
| 213 |
+
idx = random.randint(5, 15) # use high indices to avoid exploit slots
|
| 214 |
+
if idx not in noise_allocated:
|
| 215 |
+
size = random.choice([0x10, 0x20, 0x30, 0x40])
|
| 216 |
+
cmds.append(cmd_alloc(idx, size))
|
| 217 |
+
noise_allocated[idx] = size
|
| 218 |
+
elif random.random() < 0.3:
|
| 219 |
+
cmds.append(cmd_show(idx))
|
| 220 |
+
elif random.random() < 0.5:
|
| 221 |
+
data = "".join(random.choice("0123456789abcdef") for _ in range(8))
|
| 222 |
+
cmds.append(cmd_edit(idx, data))
|
| 223 |
+
|
| 224 |
+
# Get exploit commands
|
| 225 |
+
exploit_cmds = exploit_fn()
|
| 226 |
+
|
| 227 |
+
# Interleave noise between exploit steps
|
| 228 |
+
for i, cmd in enumerate(exploit_cmds):
|
| 229 |
+
cmds.append(cmd)
|
| 230 |
+
if i < len(exploit_cmds) - 1 and random.random() < 0.4:
|
| 231 |
+
# Occasional noise between exploit steps
|
| 232 |
+
for _ in range(random.randint(1, noise_between)):
|
| 233 |
+
idx = random.randint(5, 15)
|
| 234 |
+
if idx in noise_allocated:
|
| 235 |
+
cmds.append(cmd_show(idx))
|
| 236 |
+
|
| 237 |
+
return cmds
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def main():
|
| 241 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 242 |
+
|
| 243 |
+
scripts = {}
|
| 244 |
+
|
| 245 |
+
# Exploit sequences (labeled positive)
|
| 246 |
+
scripts["exploit_tcache_poison"] = exploit_tcache_poison()
|
| 247 |
+
scripts["exploit_off_by_one"] = exploit_off_by_one()
|
| 248 |
+
scripts["exploit_double_free"] = exploit_double_free()
|
| 249 |
+
|
| 250 |
+
# Noisy exploits (realistic — exploit buried in noise)
|
| 251 |
+
scripts["noisy_tcache_poison"] = noisy_exploit(exploit_tcache_poison, seed=1)
|
| 252 |
+
scripts["noisy_off_by_one"] = noisy_exploit(exploit_off_by_one, seed=2)
|
| 253 |
+
scripts["noisy_double_free"] = noisy_exploit(exploit_double_free, seed=3)
|
| 254 |
+
|
| 255 |
+
# More noisy variants with different seeds
|
| 256 |
+
for i in range(10):
|
| 257 |
+
scripts[f"noisy_tcache_v{i}"] = noisy_exploit(exploit_tcache_poison,
|
| 258 |
+
noise_before=random.randint(5, 25),
|
| 259 |
+
noise_between=random.randint(1, 5),
|
| 260 |
+
seed=100+i)
|
| 261 |
+
scripts[f"noisy_obo_v{i}"] = noisy_exploit(exploit_off_by_one,
|
| 262 |
+
noise_before=random.randint(5, 25),
|
| 263 |
+
noise_between=random.randint(1, 5),
|
| 264 |
+
seed=200+i)
|
| 265 |
+
scripts[f"noisy_dbl_v{i}"] = noisy_exploit(exploit_double_free,
|
| 266 |
+
noise_before=random.randint(5, 25),
|
| 267 |
+
noise_between=random.randint(1, 5),
|
| 268 |
+
seed=300+i)
|
| 269 |
+
|
| 270 |
+
# Random benign sequences (labeled negative)
|
| 271 |
+
for i in range(40):
|
| 272 |
+
scripts[f"benign_{i:02d}"] = random_benign(
|
| 273 |
+
n_ops=random.randint(10, 50), seed=1000+i)
|
| 274 |
+
|
| 275 |
+
# Write all scripts
|
| 276 |
+
for name, cmds in scripts.items():
|
| 277 |
+
path = OUTPUT_DIR / f"{name}.txt"
|
| 278 |
+
with open(path, "w") as f:
|
| 279 |
+
f.write("\n".join(cmds) + "\n")
|
| 280 |
+
|
| 281 |
+
print(f"Generated {len(scripts)} interaction scripts in {OUTPUT_DIR}")
|
| 282 |
+
|
| 283 |
+
# Summary
|
| 284 |
+
n_exploit = sum(1 for k in scripts if "exploit" in k or "noisy" in k)
|
| 285 |
+
n_benign = sum(1 for k in scripts if "benign" in k)
|
| 286 |
+
print(f" Exploit scripts: {n_exploit}")
|
| 287 |
+
print(f" Benign scripts: {n_benign}")
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
if __name__ == "__main__":
|
| 291 |
+
main()
|
ctf/run_ctf_validation.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_ctf_validation.py - Validate HeapTRM against a realistic CTF heap challenge.
|
| 4 |
+
|
| 5 |
+
1. Compiles vuln_heap.c
|
| 6 |
+
2. Generates exploit + benign interaction scripts
|
| 7 |
+
3. Runs each script through the instrumented binary
|
| 8 |
+
4. Builds a dataset where:
|
| 9 |
+
- Exploit scripts: ENTIRE sequence labeled 1 (the whole interaction is an exploit)
|
| 10 |
+
- Benign scripts: ENTIRE sequence labeled 0
|
| 11 |
+
5. Trains/evaluates HeapTRM on distinguishing exploit vs benign heap states
|
| 12 |
+
6. Also does per-state analysis: can TRM identify the critical exploit steps?
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import subprocess
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import json
|
| 19 |
+
import numpy as np
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
CTF_DIR = Path(__file__).resolve().parent
|
| 24 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 25 |
+
DUMP_DIR = CTF_DIR / "dumps"
|
| 26 |
+
PROCESSED_DIR = CTF_DIR / "processed"
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, str(ROOT / "dataset"))
|
| 29 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def step1_compile():
|
| 33 |
+
print("=== Step 1: Compiling CTF challenge ===")
|
| 34 |
+
binary = CTF_DIR / "vuln_heap"
|
| 35 |
+
r = subprocess.run(
|
| 36 |
+
["gcc", "-o", str(binary), str(CTF_DIR / "vuln_heap.c"),
|
| 37 |
+
"-std=c99", "-g", "-O0", "-Wno-all"],
|
| 38 |
+
capture_output=True, text=True
|
| 39 |
+
)
|
| 40 |
+
if r.returncode != 0:
|
| 41 |
+
print(f"FATAL: {r.stderr}")
|
| 42 |
+
sys.exit(1)
|
| 43 |
+
print(f" Built: {binary}")
|
| 44 |
+
return binary
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def step2_generate_scripts():
|
| 48 |
+
print("\n=== Step 2: Generating interaction scripts ===")
|
| 49 |
+
subprocess.run([sys.executable, str(CTF_DIR / "drive_ctf.py")],
|
| 50 |
+
capture_output=True, text=True)
|
| 51 |
+
scripts_dir = CTF_DIR / "scripts"
|
| 52 |
+
scripts = sorted(scripts_dir.glob("*.txt"))
|
| 53 |
+
print(f" {len(scripts)} scripts ready")
|
| 54 |
+
return scripts
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def step3_collect_dumps(binary, scripts):
|
| 58 |
+
print("\n=== Step 3: Running instrumented CTF binary ===")
|
| 59 |
+
DUMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
results = {}
|
| 62 |
+
for script in scripts:
|
| 63 |
+
name = script.stem
|
| 64 |
+
dump_file = DUMP_DIR / f"{name}.jsonl"
|
| 65 |
+
|
| 66 |
+
env = os.environ.copy()
|
| 67 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 68 |
+
env["HEAPGRID_OUT"] = str(dump_file)
|
| 69 |
+
|
| 70 |
+
with open(script) as stdin_f:
|
| 71 |
+
r = subprocess.run(
|
| 72 |
+
[str(binary)],
|
| 73 |
+
stdin=stdin_f,
|
| 74 |
+
env=env,
|
| 75 |
+
capture_output=True,
|
| 76 |
+
timeout=10,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
if dump_file.exists() and dump_file.stat().st_size > 0:
|
| 80 |
+
n_lines = sum(1 for _ in open(dump_file))
|
| 81 |
+
results[name] = n_lines
|
| 82 |
+
else:
|
| 83 |
+
results[name] = 0
|
| 84 |
+
|
| 85 |
+
ok = sum(1 for v in results.values() if v > 0)
|
| 86 |
+
print(f" {ok}/{len(results)} scripts produced dumps")
|
| 87 |
+
|
| 88 |
+
# Show distribution
|
| 89 |
+
exploit_states = sum(v for k, v in results.items()
|
| 90 |
+
if "exploit" in k or "noisy" in k)
|
| 91 |
+
benign_states = sum(v for k, v in results.items() if "benign" in k)
|
| 92 |
+
print(f" Exploit states: {exploit_states}")
|
| 93 |
+
print(f" Benign states: {benign_states}")
|
| 94 |
+
|
| 95 |
+
return results
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def step4_build_dataset(results):
|
| 99 |
+
print("\n=== Step 4: Building CTF dataset ===")
|
| 100 |
+
from dataset_gen import state_to_grid, load_dump
|
| 101 |
+
|
| 102 |
+
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
|
| 104 |
+
all_grids = []
|
| 105 |
+
all_labels = []
|
| 106 |
+
all_names = []
|
| 107 |
+
|
| 108 |
+
for name, n_states in sorted(results.items()):
|
| 109 |
+
if n_states == 0:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
dump_path = DUMP_DIR / f"{name}.jsonl"
|
| 113 |
+
states = load_dump(dump_path)
|
| 114 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 115 |
+
|
| 116 |
+
# Label: is this an exploit interaction?
|
| 117 |
+
is_exploit = "exploit" in name or "noisy" in name
|
| 118 |
+
|
| 119 |
+
if is_exploit:
|
| 120 |
+
# For exploit scripts, label the LAST 30% as positive
|
| 121 |
+
# (the critical exploit steps at the end)
|
| 122 |
+
labels = np.zeros(len(states), dtype=np.int64)
|
| 123 |
+
cutoff = max(1, int(len(states) * 0.7))
|
| 124 |
+
labels[cutoff:] = 1
|
| 125 |
+
else:
|
| 126 |
+
# Benign: all states are 0
|
| 127 |
+
labels = np.zeros(len(states), dtype=np.int64)
|
| 128 |
+
|
| 129 |
+
all_grids.append(grids)
|
| 130 |
+
all_labels.append(labels)
|
| 131 |
+
all_names.extend([name] * len(states))
|
| 132 |
+
|
| 133 |
+
X = np.concatenate(all_grids)
|
| 134 |
+
y = np.concatenate(all_labels)
|
| 135 |
+
|
| 136 |
+
print(f" Total states: {len(X)} ({y.sum()} positive, {len(y) - y.sum()} negative)")
|
| 137 |
+
|
| 138 |
+
# Split: hold out some exploit variants + benign for test
|
| 139 |
+
# Train on: exploit_*, noisy_*_v0-v6, benign_00-benign_29
|
| 140 |
+
# Test on: noisy_*_v7-v9, benign_30-benign_39
|
| 141 |
+
train_mask = np.zeros(len(X), dtype=bool)
|
| 142 |
+
test_mask = np.zeros(len(X), dtype=bool)
|
| 143 |
+
|
| 144 |
+
idx = 0
|
| 145 |
+
for name, n_states in sorted(results.items()):
|
| 146 |
+
if n_states == 0:
|
| 147 |
+
continue
|
| 148 |
+
end = idx + n_states
|
| 149 |
+
|
| 150 |
+
if "benign" in name:
|
| 151 |
+
num = int(name.split("_")[1])
|
| 152 |
+
if num < 30:
|
| 153 |
+
train_mask[idx:end] = True
|
| 154 |
+
else:
|
| 155 |
+
test_mask[idx:end] = True
|
| 156 |
+
elif "noisy" in name:
|
| 157 |
+
# v7, v8, v9 are test
|
| 158 |
+
if any(f"v{i}" in name for i in [7, 8, 9]):
|
| 159 |
+
test_mask[idx:end] = True
|
| 160 |
+
else:
|
| 161 |
+
train_mask[idx:end] = True
|
| 162 |
+
else:
|
| 163 |
+
# clean exploit scripts -> train
|
| 164 |
+
train_mask[idx:end] = True
|
| 165 |
+
|
| 166 |
+
idx = end
|
| 167 |
+
|
| 168 |
+
X_train, y_train = X[train_mask], y[train_mask]
|
| 169 |
+
X_test, y_test = X[test_mask], y[test_mask]
|
| 170 |
+
|
| 171 |
+
print(f" Train: {len(X_train)} states ({y_train.sum()} positive)")
|
| 172 |
+
print(f" Test: {len(X_test)} states ({y_test.sum()} positive)")
|
| 173 |
+
|
| 174 |
+
np.save(PROCESSED_DIR / "X_train.npy", X_train)
|
| 175 |
+
np.save(PROCESSED_DIR / "y_train.npy", y_train)
|
| 176 |
+
np.save(PROCESSED_DIR / "X_test.npy", X_test)
|
| 177 |
+
np.save(PROCESSED_DIR / "y_test.npy", y_test)
|
| 178 |
+
|
| 179 |
+
# Also save name mapping for analysis
|
| 180 |
+
np.save(PROCESSED_DIR / "names.npy", np.array(all_names))
|
| 181 |
+
|
| 182 |
+
return X_train, y_train, X_test, y_test
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def step5_train_and_evaluate():
|
| 186 |
+
print("\n=== Step 5: Training HeapTRM on CTF data ===")
|
| 187 |
+
from trm_heap import train_model
|
| 188 |
+
|
| 189 |
+
model, metrics = train_model(
|
| 190 |
+
data_dir=PROCESSED_DIR,
|
| 191 |
+
output_dir=CTF_DIR / "checkpoints",
|
| 192 |
+
hidden_dim=128,
|
| 193 |
+
n_outer=2,
|
| 194 |
+
n_inner=3,
|
| 195 |
+
epochs=100,
|
| 196 |
+
batch_size=32,
|
| 197 |
+
lr=5e-4,
|
| 198 |
+
use_focal=True,
|
| 199 |
+
)
|
| 200 |
+
return model, metrics
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def step6_per_script_analysis(model):
|
| 204 |
+
"""Analyze predictions per interaction script to see if TRM
|
| 205 |
+
correctly identifies exploit vs benign SCRIPTS (not just states)."""
|
| 206 |
+
import torch
|
| 207 |
+
from dataset_gen import load_dump, state_to_grid
|
| 208 |
+
|
| 209 |
+
print("\n=== Step 6: Per-script exploit detection ===")
|
| 210 |
+
print(f"{'Script':45s} | {'States':>6s} | {'Pred+':>5s} | {'Label':>5s} | {'Verdict':>10s}")
|
| 211 |
+
print("-" * 85)
|
| 212 |
+
|
| 213 |
+
tp_scripts = 0
|
| 214 |
+
fp_scripts = 0
|
| 215 |
+
fn_scripts = 0
|
| 216 |
+
tn_scripts = 0
|
| 217 |
+
|
| 218 |
+
model.eval()
|
| 219 |
+
for dump_file in sorted(DUMP_DIR.glob("*.jsonl")):
|
| 220 |
+
name = dump_file.stem
|
| 221 |
+
states = load_dump(dump_file)
|
| 222 |
+
if not states:
|
| 223 |
+
continue
|
| 224 |
+
|
| 225 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 226 |
+
X = torch.from_numpy(grids).long()
|
| 227 |
+
|
| 228 |
+
with torch.no_grad():
|
| 229 |
+
logits = model(X)
|
| 230 |
+
preds = logits.argmax(dim=1).numpy()
|
| 231 |
+
|
| 232 |
+
n_positive = preds.sum()
|
| 233 |
+
is_exploit = "exploit" in name or "noisy" in name
|
| 234 |
+
|
| 235 |
+
# Script-level verdict: if ANY state is predicted positive -> exploit
|
| 236 |
+
script_pred = n_positive > 0
|
| 237 |
+
correct = script_pred == is_exploit
|
| 238 |
+
|
| 239 |
+
if is_exploit and script_pred:
|
| 240 |
+
tp_scripts += 1
|
| 241 |
+
verdict = "TP"
|
| 242 |
+
elif is_exploit and not script_pred:
|
| 243 |
+
fn_scripts += 1
|
| 244 |
+
verdict = "FN"
|
| 245 |
+
elif not is_exploit and script_pred:
|
| 246 |
+
fp_scripts += 1
|
| 247 |
+
verdict = "FP"
|
| 248 |
+
else:
|
| 249 |
+
tn_scripts += 1
|
| 250 |
+
verdict = "TN"
|
| 251 |
+
|
| 252 |
+
print(f"{name:45s} | {len(states):6d} | {n_positive:5d} | "
|
| 253 |
+
f"{'EXP' if is_exploit else 'BEN':>5s} | {verdict:>10s}")
|
| 254 |
+
|
| 255 |
+
print("-" * 85)
|
| 256 |
+
total = tp_scripts + fp_scripts + fn_scripts + tn_scripts
|
| 257 |
+
print(f"\nScript-level detection:")
|
| 258 |
+
print(f" TP={tp_scripts} FP={fp_scripts} FN={fn_scripts} TN={tn_scripts}")
|
| 259 |
+
prec = tp_scripts / max(tp_scripts + fp_scripts, 1)
|
| 260 |
+
rec = tp_scripts / max(tp_scripts + fn_scripts, 1)
|
| 261 |
+
f1 = 2 * prec * rec / max(prec + rec, 1e-8)
|
| 262 |
+
print(f" Precision={prec:.3f} Recall={rec:.3f} F1={f1:.3f}")
|
| 263 |
+
print(f" Accuracy={(tp_scripts + tn_scripts) / max(total, 1):.3f}")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def main():
|
| 267 |
+
binary = step1_compile()
|
| 268 |
+
scripts = step2_generate_scripts()
|
| 269 |
+
results = step3_collect_dumps(binary, scripts)
|
| 270 |
+
X_train, y_train, X_test, y_test = step4_build_dataset(results)
|
| 271 |
+
model, metrics = step5_train_and_evaluate()
|
| 272 |
+
step6_per_script_analysis(model)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if __name__ == "__main__":
|
| 276 |
+
main()
|
ctf/vuln_heap.c
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* vuln_heap.c - Realistic CTF-style heap challenge
|
| 3 |
+
*
|
| 4 |
+
* Menu-driven note manager with:
|
| 5 |
+
* 1) Allocate note (up to 16 notes, size <= 0x80)
|
| 6 |
+
* 2) Edit note (off-by-one null byte overflow)
|
| 7 |
+
* 3) Show note
|
| 8 |
+
* 4) Delete note (UAF: doesn't clear pointer)
|
| 9 |
+
* 5) Exit
|
| 10 |
+
*
|
| 11 |
+
* Vulnerabilities:
|
| 12 |
+
* - Off-by-one null byte in edit (can corrupt next chunk's prev_inuse)
|
| 13 |
+
* - Use-after-free in show/edit (pointer not zeroed on delete)
|
| 14 |
+
*
|
| 15 |
+
* Reads commands from stdin as integers, one per line.
|
| 16 |
+
* Format: <menu_choice> [args...]
|
| 17 |
+
*
|
| 18 |
+
* This is driven by a script that sends commands via stdin.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
#include <stdio.h>
|
| 22 |
+
#include <stdlib.h>
|
| 23 |
+
#include <string.h>
|
| 24 |
+
#include <unistd.h>
|
| 25 |
+
|
| 26 |
+
#define MAX_NOTES 16
|
| 27 |
+
#define MAX_SIZE 0x80
|
| 28 |
+
|
| 29 |
+
struct note {
|
| 30 |
+
char *data;
|
| 31 |
+
size_t size;
|
| 32 |
+
int in_use;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
struct note notes[MAX_NOTES];
|
| 36 |
+
|
| 37 |
+
void alloc_note() {
|
| 38 |
+
int idx, size;
|
| 39 |
+
if (scanf("%d %d", &idx, &size) != 2) return;
|
| 40 |
+
if (idx < 0 || idx >= MAX_NOTES || size <= 0 || size > MAX_SIZE) return;
|
| 41 |
+
if (notes[idx].data != NULL) return; /* slot occupied */
|
| 42 |
+
|
| 43 |
+
notes[idx].data = malloc(size);
|
| 44 |
+
if (!notes[idx].data) return;
|
| 45 |
+
memset(notes[idx].data, 0, size);
|
| 46 |
+
notes[idx].size = size;
|
| 47 |
+
notes[idx].in_use = 1;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
void edit_note() {
|
| 51 |
+
int idx;
|
| 52 |
+
if (scanf("%d", &idx) != 1) return;
|
| 53 |
+
if (idx < 0 || idx >= MAX_NOTES || notes[idx].data == NULL) return;
|
| 54 |
+
|
| 55 |
+
/* Read exactly size bytes + OFF-BY-ONE null byte overflow */
|
| 56 |
+
char buf[MAX_SIZE + 1];
|
| 57 |
+
size_t sz = notes[idx].size;
|
| 58 |
+
if (sz > MAX_SIZE) sz = MAX_SIZE;
|
| 59 |
+
|
| 60 |
+
/* Read hex-encoded data from stdin */
|
| 61 |
+
char hex[MAX_SIZE * 2 + 4];
|
| 62 |
+
if (scanf("%s", hex) != 1) return;
|
| 63 |
+
|
| 64 |
+
size_t len = strlen(hex) / 2;
|
| 65 |
+
if (len > sz) len = sz;
|
| 66 |
+
|
| 67 |
+
for (size_t i = 0; i < len; i++) {
|
| 68 |
+
unsigned int byte;
|
| 69 |
+
sscanf(hex + i * 2, "%2x", &byte);
|
| 70 |
+
buf[i] = (char)byte;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* BUG: off-by-one null byte write */
|
| 74 |
+
memcpy(notes[idx].data, buf, len);
|
| 75 |
+
notes[idx].data[len] = '\0'; /* writes one byte past if len == size */
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
void show_note() {
|
| 79 |
+
int idx;
|
| 80 |
+
if (scanf("%d", &idx) != 1) return;
|
| 81 |
+
if (idx < 0 || idx >= MAX_NOTES || notes[idx].data == NULL) return;
|
| 82 |
+
|
| 83 |
+
/* UAF: can show freed data (pointer not cleared on delete) */
|
| 84 |
+
write(STDOUT_FILENO, notes[idx].data, notes[idx].size);
|
| 85 |
+
write(STDOUT_FILENO, "\n", 1);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
void delete_note() {
|
| 89 |
+
int idx;
|
| 90 |
+
if (scanf("%d", &idx) != 1) return;
|
| 91 |
+
if (idx < 0 || idx >= MAX_NOTES || notes[idx].data == NULL) return;
|
| 92 |
+
|
| 93 |
+
free(notes[idx].data);
|
| 94 |
+
/* BUG: UAF - don't clear pointer or in_use flag */
|
| 95 |
+
notes[idx].in_use = 0;
|
| 96 |
+
/* notes[idx].data = NULL; <-- should do this but don't */
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
int main() {
|
| 100 |
+
setbuf(stdin, NULL);
|
| 101 |
+
setbuf(stdout, NULL);
|
| 102 |
+
setbuf(stderr, NULL);
|
| 103 |
+
|
| 104 |
+
int choice;
|
| 105 |
+
while (1) {
|
| 106 |
+
if (scanf("%d", &choice) != 1) break;
|
| 107 |
+
switch (choice) {
|
| 108 |
+
case 1: alloc_note(); break;
|
| 109 |
+
case 2: edit_note(); break;
|
| 110 |
+
case 3: show_note(); break;
|
| 111 |
+
case 4: delete_note(); break;
|
| 112 |
+
case 5: return 0;
|
| 113 |
+
default: break;
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
return 0;
|
| 117 |
+
}
|
cve_tests/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Real CVE Testing
|
| 2 |
+
|
| 3 |
+
Testing HeapTRM classifier against real-world heap CVE exploits.
|
| 4 |
+
|
| 5 |
+
## Target CVEs
|
| 6 |
+
|
| 7 |
+
1. **CVE-2023-4911** (Looney Tunables) - Heap overflow in glibc ld.so GLIBC_TUNABLES parsing
|
| 8 |
+
2. **CVE-2024-2961** - Buffer overflow in glibc iconv() ISO-2022-CN-EXT
|
| 9 |
+
3. **CVE-2023-6246** - Heap overflow in glibc syslog()/vsyslog()
|
| 10 |
+
|
| 11 |
+
## Approach
|
| 12 |
+
|
| 13 |
+
For each CVE:
|
| 14 |
+
1. Set up vulnerable glibc version in Docker
|
| 15 |
+
2. Build exploit PoC
|
| 16 |
+
3. Instrument with our LD_PRELOAD harness
|
| 17 |
+
4. Run exploit + benign workloads
|
| 18 |
+
5. Evaluate classifier: can it distinguish exploit from benign?
|
cve_tests/cve_sims.c
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* cve_sims.c - Realistic programs simulating real CVE bug classes.
|
| 3 |
+
*
|
| 4 |
+
* Each function implements a realistic workload with a heap vulnerability
|
| 5 |
+
* matching a real CVE pattern. The harness instruments heap operations
|
| 6 |
+
* and the classifier should detect the exploit states.
|
| 7 |
+
*
|
| 8 |
+
* Usage: ./cve_sims <test_num> [exploit|benign]
|
| 9 |
+
*
|
| 10 |
+
* Tests:
|
| 11 |
+
* 1: Syslog-style heap overflow (CVE-2023-6246 pattern)
|
| 12 |
+
* 2: String conversion buffer overflow (CVE-2024-2961 pattern)
|
| 13 |
+
* 3: Config parser use-after-free (common webapp CVE pattern)
|
| 14 |
+
* 4: JSON parser double-free (common parser CVE pattern)
|
| 15 |
+
* 5: HTTP header heap overflow (common server CVE pattern)
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
#include <stdio.h>
|
| 19 |
+
#include <stdlib.h>
|
| 20 |
+
#include <string.h>
|
| 21 |
+
#include <unistd.h>
|
| 22 |
+
|
| 23 |
+
/* ==========================================================
|
| 24 |
+
* Test 1: Syslog-style heap overflow (CVE-2023-6246 pattern)
|
| 25 |
+
* A logging function that heap-allocates a format buffer.
|
| 26 |
+
* Vulnerable: doesn't account for format expansion.
|
| 27 |
+
* ========================================================== */
|
| 28 |
+
void test_syslog(int exploit) {
|
| 29 |
+
// Normal logging workload
|
| 30 |
+
for (int i = 0; i < 5; i++) {
|
| 31 |
+
char *logbuf = malloc(128);
|
| 32 |
+
snprintf(logbuf, 128, "Normal log message %d", i);
|
| 33 |
+
free(logbuf);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Allocate adjacent chunks
|
| 37 |
+
char *guard1 = malloc(64);
|
| 38 |
+
char *logbuf = malloc(64); // will be overflowed
|
| 39 |
+
char *guard2 = malloc(64);
|
| 40 |
+
memset(guard1, 'A', 64);
|
| 41 |
+
memset(guard2, 'B', 64);
|
| 42 |
+
|
| 43 |
+
if (exploit) {
|
| 44 |
+
// BUG: format string expands beyond allocated size
|
| 45 |
+
char payload[256];
|
| 46 |
+
memset(payload, 'X', 200);
|
| 47 |
+
payload[200] = '\0';
|
| 48 |
+
// Overflow logbuf into guard2's header
|
| 49 |
+
memcpy(logbuf, payload, 200); // writes 200 bytes into 64-byte buffer
|
| 50 |
+
} else {
|
| 51 |
+
snprintf(logbuf, 64, "Safe message");
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
free(logbuf);
|
| 55 |
+
free(guard1);
|
| 56 |
+
free(guard2);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* ==========================================================
|
| 60 |
+
* Test 2: String conversion overflow (CVE-2024-2961 pattern)
|
| 61 |
+
* Character encoding converter with output buffer miscalculation.
|
| 62 |
+
* ========================================================== */
|
| 63 |
+
void test_iconv(int exploit) {
|
| 64 |
+
// Normal conversion workload
|
| 65 |
+
for (int i = 0; i < 8; i++) {
|
| 66 |
+
size_t len = 32 + (i * 8);
|
| 67 |
+
char *inbuf = malloc(len);
|
| 68 |
+
char *outbuf = malloc(len * 2);
|
| 69 |
+
memset(inbuf, 'A' + i, len);
|
| 70 |
+
// "convert" (just copy for simulation)
|
| 71 |
+
memcpy(outbuf, inbuf, len);
|
| 72 |
+
free(outbuf);
|
| 73 |
+
free(inbuf);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Vulnerable conversion
|
| 77 |
+
char *input = malloc(128);
|
| 78 |
+
memset(input, 0xC0, 128); // multi-byte chars that expand
|
| 79 |
+
|
| 80 |
+
// BUG: output buffer too small for expanded encoding
|
| 81 |
+
size_t outsize = exploit ? 64 : 256; // exploit: undersized
|
| 82 |
+
char *output = malloc(outsize);
|
| 83 |
+
char *metadata = malloc(32); // adjacent chunk that gets corrupted
|
| 84 |
+
strcpy(metadata, "SECURE_TOKEN");
|
| 85 |
+
|
| 86 |
+
if (exploit) {
|
| 87 |
+
// Overflow: 128 bytes of multi-byte -> 192 bytes output, but only 64 allocated
|
| 88 |
+
memcpy(output, input, 128); // overflow into metadata
|
| 89 |
+
} else {
|
| 90 |
+
memcpy(output, input, outsize - 1);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
free(input);
|
| 94 |
+
free(output);
|
| 95 |
+
free(metadata);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* ==========================================================
|
| 99 |
+
* Test 3: Config parser use-after-free
|
| 100 |
+
* Simulates a webapp config reload race condition.
|
| 101 |
+
* ========================================================== */
|
| 102 |
+
struct config {
|
| 103 |
+
char *hostname;
|
| 104 |
+
char *db_url;
|
| 105 |
+
int port;
|
| 106 |
+
void (*handler)(void);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
void dummy_handler(void) { }
|
| 110 |
+
|
| 111 |
+
void test_config_uaf(int exploit) {
|
| 112 |
+
// Normal config lifecycle
|
| 113 |
+
for (int i = 0; i < 3; i++) {
|
| 114 |
+
struct config *cfg = malloc(sizeof(struct config));
|
| 115 |
+
cfg->hostname = malloc(64);
|
| 116 |
+
cfg->db_url = malloc(128);
|
| 117 |
+
cfg->port = 8080 + i;
|
| 118 |
+
cfg->handler = dummy_handler;
|
| 119 |
+
strcpy(cfg->hostname, "localhost");
|
| 120 |
+
strcpy(cfg->db_url, "postgres://db:5432/app");
|
| 121 |
+
free(cfg->db_url);
|
| 122 |
+
free(cfg->hostname);
|
| 123 |
+
free(cfg);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Vulnerable path: config freed but pointer reused
|
| 127 |
+
struct config *old_cfg = malloc(sizeof(struct config));
|
| 128 |
+
old_cfg->hostname = malloc(64);
|
| 129 |
+
old_cfg->db_url = malloc(128);
|
| 130 |
+
old_cfg->handler = dummy_handler;
|
| 131 |
+
strcpy(old_cfg->hostname, "prod-server");
|
| 132 |
+
strcpy(old_cfg->db_url, "postgres://prod:5432/maindb");
|
| 133 |
+
|
| 134 |
+
// "Reload" frees old config
|
| 135 |
+
free(old_cfg->db_url);
|
| 136 |
+
free(old_cfg->hostname);
|
| 137 |
+
free(old_cfg);
|
| 138 |
+
|
| 139 |
+
if (exploit) {
|
| 140 |
+
// UAF: write through the dangling pointer AFTER free
|
| 141 |
+
// This directly modifies freed chunk data — detectable by hash check
|
| 142 |
+
memset(old_cfg, 0x41, sizeof(struct config)); // write to freed memory!
|
| 143 |
+
|
| 144 |
+
// Then allocate — the tcache returns the corrupted chunk
|
| 145 |
+
struct config *reused = malloc(sizeof(struct config));
|
| 146 |
+
// reused->handler is now 0x4141414141414141
|
| 147 |
+
void *leaked = malloc(64);
|
| 148 |
+
free(leaked);
|
| 149 |
+
free(reused);
|
| 150 |
+
} else {
|
| 151 |
+
// Safe: new config allocated cleanly
|
| 152 |
+
struct config *new_cfg = malloc(sizeof(struct config));
|
| 153 |
+
new_cfg->hostname = malloc(64);
|
| 154 |
+
strcpy(new_cfg->hostname, "new-server");
|
| 155 |
+
free(new_cfg->hostname);
|
| 156 |
+
free(new_cfg);
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* ==========================================================
|
| 161 |
+
* Test 4: JSON parser double-free
|
| 162 |
+
* Simulates a parser that double-frees on error path.
|
| 163 |
+
* ========================================================== */
|
| 164 |
+
void test_json_double_free(int exploit) {
|
| 165 |
+
// Normal JSON parsing
|
| 166 |
+
for (int i = 0; i < 6; i++) {
|
| 167 |
+
char *key = malloc(32);
|
| 168 |
+
char *value = malloc(64);
|
| 169 |
+
snprintf(key, 32, "field_%d", i);
|
| 170 |
+
snprintf(value, 64, "value_%d", i);
|
| 171 |
+
free(value);
|
| 172 |
+
free(key);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Parse a "document"
|
| 176 |
+
char *root = malloc(256);
|
| 177 |
+
char *child1 = malloc(128);
|
| 178 |
+
char *child2 = malloc(128);
|
| 179 |
+
char *child3 = malloc(64);
|
| 180 |
+
strcpy(root, "{\"data\": [...]}");
|
| 181 |
+
strcpy(child1, "{\"name\": \"Alice\"}");
|
| 182 |
+
strcpy(child2, "{\"name\": \"Bob\"}");
|
| 183 |
+
strcpy(child3, "{\"id\": 42}");
|
| 184 |
+
|
| 185 |
+
// Cleanup
|
| 186 |
+
free(child3);
|
| 187 |
+
free(child2);
|
| 188 |
+
|
| 189 |
+
if (exploit) {
|
| 190 |
+
// BUG: error path frees child2 again (double-free)
|
| 191 |
+
// On modern glibc this may abort, but harness captures before abort
|
| 192 |
+
free(child2); // double free!
|
| 193 |
+
|
| 194 |
+
// If glibc doesn't abort (older versions), attacker controls allocation
|
| 195 |
+
char *evil = malloc(128);
|
| 196 |
+
if (evil) {
|
| 197 |
+
memset(evil, 'X', 128);
|
| 198 |
+
free(evil);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
free(child1);
|
| 203 |
+
free(root);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* ==========================================================
|
| 207 |
+
* Test 5: HTTP header heap overflow
|
| 208 |
+
* Simulates a web server that doesn't validate header length.
|
| 209 |
+
* ========================================================== */
|
| 210 |
+
void test_http_overflow(int exploit) {
|
| 211 |
+
// Normal request processing
|
| 212 |
+
for (int i = 0; i < 4; i++) {
|
| 213 |
+
char *method = malloc(16);
|
| 214 |
+
char *path = malloc(256);
|
| 215 |
+
char *headers = malloc(512);
|
| 216 |
+
strcpy(method, "GET");
|
| 217 |
+
snprintf(path, 256, "/api/v1/resource/%d", i);
|
| 218 |
+
snprintf(headers, 512, "Host: example.com\r\nUser-Agent: normal\r\n");
|
| 219 |
+
free(headers);
|
| 220 |
+
free(path);
|
| 221 |
+
free(method);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Vulnerable request handling
|
| 225 |
+
char *req_headers = malloc(256);
|
| 226 |
+
char *session = malloc(64);
|
| 227 |
+
char *response = malloc(512);
|
| 228 |
+
strcpy(session, "SESSION_ID=abc123");
|
| 229 |
+
strcpy(response, "HTTP/1.1 200 OK\r\n");
|
| 230 |
+
|
| 231 |
+
if (exploit) {
|
| 232 |
+
// BUG: attacker sends oversized header, overflows into session chunk
|
| 233 |
+
char evil_header[512];
|
| 234 |
+
memset(evil_header, 'H', 400);
|
| 235 |
+
evil_header[400] = '\0';
|
| 236 |
+
memcpy(req_headers, evil_header, 400); // overflow
|
| 237 |
+
} else {
|
| 238 |
+
strcpy(req_headers, "Host: example.com\r\n");
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
free(req_headers);
|
| 242 |
+
free(session);
|
| 243 |
+
free(response);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
int main(int argc, char *argv[]) {
|
| 247 |
+
setbuf(stdout, NULL);
|
| 248 |
+
setbuf(stderr, NULL);
|
| 249 |
+
|
| 250 |
+
int test = argc > 1 ? atoi(argv[1]) : 1;
|
| 251 |
+
int exploit = argc > 2 && strcmp(argv[2], "exploit") == 0;
|
| 252 |
+
|
| 253 |
+
switch (test) {
|
| 254 |
+
case 1: test_syslog(exploit); break;
|
| 255 |
+
case 2: test_iconv(exploit); break;
|
| 256 |
+
case 3: test_config_uaf(exploit); break;
|
| 257 |
+
case 4: test_json_double_free(exploit); break;
|
| 258 |
+
case 5: test_http_overflow(exploit); break;
|
| 259 |
+
default:
|
| 260 |
+
fprintf(stderr, "Usage: %s <1-5> [exploit|benign]\n", argv[0]);
|
| 261 |
+
return 1;
|
| 262 |
+
}
|
| 263 |
+
return 0;
|
| 264 |
+
}
|
cve_tests/run_cve_tests.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_cve_tests.py - Test HeapTRM classifier against realistic CVE patterns.
|
| 4 |
+
|
| 5 |
+
Runs 5 simulated CVE patterns in exploit vs benign mode, instruments with
|
| 6 |
+
the LD_PRELOAD harness, and evaluates whether the classifier trained on
|
| 7 |
+
how2heap can detect real-world vulnerability patterns it has NEVER seen.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
import subprocess
|
| 14 |
+
import tempfile
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 20 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 21 |
+
sys.path.insert(0, str(ROOT / "dataset"))
|
| 22 |
+
|
| 23 |
+
from trm_heap import HeapTRM, focal_loss
|
| 24 |
+
from dataset_gen import state_to_grid, load_dump
|
| 25 |
+
|
| 26 |
+
BINARY = ROOT / "cve_tests" / "cve_sims"
|
| 27 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 28 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 29 |
+
|
| 30 |
+
CVE_NAMES = {
|
| 31 |
+
1: "CVE-2023-6246 (syslog heap overflow)",
|
| 32 |
+
2: "CVE-2024-2961 (iconv buffer overflow)",
|
| 33 |
+
3: "Config parser UAF (common webapp CVE)",
|
| 34 |
+
4: "JSON parser double-free (common parser CVE)",
|
| 35 |
+
5: "HTTP header overflow (common server CVE)",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def compile_binary():
|
| 40 |
+
src = ROOT / "cve_tests" / "cve_sims.c"
|
| 41 |
+
r = subprocess.run(
|
| 42 |
+
["gcc", "-o", str(BINARY), str(src), "-std=c99", "-g", "-O0", "-Wno-all"],
|
| 43 |
+
capture_output=True, text=True
|
| 44 |
+
)
|
| 45 |
+
if r.returncode != 0:
|
| 46 |
+
print(f"Compile failed: {r.stderr}")
|
| 47 |
+
sys.exit(1)
|
| 48 |
+
print(f"Compiled: {BINARY}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def run_and_dump(test_num, mode):
|
| 52 |
+
"""Run CVE sim and return heap dump states."""
|
| 53 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 54 |
+
env = os.environ.copy()
|
| 55 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 56 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 57 |
+
|
| 58 |
+
subprocess.run(
|
| 59 |
+
[str(BINARY), str(test_num), mode],
|
| 60 |
+
env=env, capture_output=True, timeout=10
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
states = []
|
| 64 |
+
if os.path.exists(dump_path):
|
| 65 |
+
with open(dump_path) as f:
|
| 66 |
+
for line in f:
|
| 67 |
+
if line.strip():
|
| 68 |
+
states.append(json.loads(line.strip()))
|
| 69 |
+
os.unlink(dump_path)
|
| 70 |
+
return states
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def train_classifier():
|
| 74 |
+
"""Train on how2heap data (our existing training set)."""
|
| 75 |
+
print("Training classifier on how2heap data...")
|
| 76 |
+
|
| 77 |
+
# Load all how2heap dumps (base techniques only)
|
| 78 |
+
all_X, all_y = [], []
|
| 79 |
+
for dump_file in sorted((ROOT / "data" / "dumps").glob("*.jsonl")):
|
| 80 |
+
stem = dump_file.stem
|
| 81 |
+
if "_g2" in stem or "_old" in stem:
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
states = load_dump(dump_file)
|
| 85 |
+
if not states:
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 89 |
+
n = len(grids)
|
| 90 |
+
labels = np.zeros(n, dtype=np.int64)
|
| 91 |
+
labels[max(n - max(1, int(n * 0.3)), 0):] = 1
|
| 92 |
+
if labels.sum() == 0:
|
| 93 |
+
labels[-1] = 1
|
| 94 |
+
|
| 95 |
+
# Also check for double-free / fd corruption
|
| 96 |
+
for i, state in enumerate(states[:n]):
|
| 97 |
+
for chunk in state.get("chunks", []):
|
| 98 |
+
if chunk.get("is_double_freed", 0):
|
| 99 |
+
labels[i] = 1
|
| 100 |
+
if chunk.get("state") == 2 and chunk.get("fd_idx") == -2:
|
| 101 |
+
labels[i] = 1
|
| 102 |
+
|
| 103 |
+
all_X.append(grids)
|
| 104 |
+
all_y.append(labels)
|
| 105 |
+
|
| 106 |
+
X = np.concatenate(all_X)
|
| 107 |
+
y = np.concatenate(all_y)
|
| 108 |
+
print(f" Training data: {len(X)} states ({y.sum()} positive)")
|
| 109 |
+
|
| 110 |
+
model = HeapTRM(hidden_dim=128, n_outer=2, n_inner=3).to(DEVICE)
|
| 111 |
+
opt = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.01)
|
| 112 |
+
X_t = torch.from_numpy(X).long().to(DEVICE)
|
| 113 |
+
y_t = torch.from_numpy(y).long().to(DEVICE)
|
| 114 |
+
|
| 115 |
+
for ep in range(100):
|
| 116 |
+
model.train()
|
| 117 |
+
perm = torch.randperm(len(X_t), device=DEVICE)
|
| 118 |
+
for i in range(0, len(X_t), 128):
|
| 119 |
+
idx = perm[i:i + 128]
|
| 120 |
+
loss = focal_loss(model(X_t[idx]), y_t[idx], alpha=0.75)
|
| 121 |
+
opt.zero_grad()
|
| 122 |
+
loss.backward()
|
| 123 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 124 |
+
opt.step()
|
| 125 |
+
|
| 126 |
+
print(" Training complete.")
|
| 127 |
+
return model
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def evaluate_cve(model, test_num, n_runs=20):
|
| 131 |
+
"""Run exploit and benign N times, see if classifier distinguishes them."""
|
| 132 |
+
model.eval()
|
| 133 |
+
|
| 134 |
+
results = {"exploit": [], "benign": []}
|
| 135 |
+
|
| 136 |
+
for mode in ["exploit", "benign"]:
|
| 137 |
+
for run in range(n_runs):
|
| 138 |
+
states = run_and_dump(test_num, mode)
|
| 139 |
+
if not states:
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 143 |
+
X = torch.from_numpy(grids).long().to(DEVICE)
|
| 144 |
+
|
| 145 |
+
with torch.no_grad():
|
| 146 |
+
logits = model(X)
|
| 147 |
+
probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy()
|
| 148 |
+
preds = logits.argmax(dim=1).cpu().numpy()
|
| 149 |
+
|
| 150 |
+
# Max exploit probability across all states in this run
|
| 151 |
+
max_prob = float(probs.max())
|
| 152 |
+
any_positive = int(preds.sum() > 0)
|
| 153 |
+
|
| 154 |
+
results[mode].append({
|
| 155 |
+
"max_prob": max_prob,
|
| 156 |
+
"any_positive": any_positive,
|
| 157 |
+
"n_states": len(states),
|
| 158 |
+
"n_flagged": int(preds.sum()),
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
return results
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def main():
|
| 165 |
+
compile_binary()
|
| 166 |
+
model = train_classifier()
|
| 167 |
+
|
| 168 |
+
print("\n" + "=" * 80)
|
| 169 |
+
print("CVE PATTERN DETECTION RESULTS")
|
| 170 |
+
print("(Classifier trained on how2heap, tested on unseen CVE patterns)")
|
| 171 |
+
print("=" * 80)
|
| 172 |
+
|
| 173 |
+
all_results = {}
|
| 174 |
+
for test_num in range(1, 6):
|
| 175 |
+
name = CVE_NAMES[test_num]
|
| 176 |
+
print(f"\n--- Test {test_num}: {name} ---")
|
| 177 |
+
|
| 178 |
+
results = evaluate_cve(model, test_num, n_runs=30)
|
| 179 |
+
|
| 180 |
+
# Script-level detection: does ANY state get flagged?
|
| 181 |
+
exploit_detected = sum(r["any_positive"] for r in results["exploit"])
|
| 182 |
+
benign_detected = sum(r["any_positive"] for r in results["benign"])
|
| 183 |
+
exploit_total = len(results["exploit"])
|
| 184 |
+
benign_total = len(results["benign"])
|
| 185 |
+
|
| 186 |
+
# Max probabilities
|
| 187 |
+
exploit_probs = [r["max_prob"] for r in results["exploit"]]
|
| 188 |
+
benign_probs = [r["max_prob"] for r in results["benign"]]
|
| 189 |
+
|
| 190 |
+
tp = exploit_detected
|
| 191 |
+
fp = benign_detected
|
| 192 |
+
fn = exploit_total - exploit_detected
|
| 193 |
+
tn = benign_total - benign_detected
|
| 194 |
+
|
| 195 |
+
prec = tp / max(tp + fp, 1)
|
| 196 |
+
rec = tp / max(tp + fn, 1)
|
| 197 |
+
f1 = 2 * prec * rec / max(prec + rec, 1e-8)
|
| 198 |
+
|
| 199 |
+
print(f" Exploit runs flagged: {exploit_detected}/{exploit_total}")
|
| 200 |
+
print(f" Benign runs flagged: {benign_detected}/{benign_total}")
|
| 201 |
+
print(f" TP={tp} FP={fp} FN={fn} TN={tn}")
|
| 202 |
+
print(f" Precision={prec:.3f} Recall={rec:.3f} F1={f1:.3f}")
|
| 203 |
+
print(f" Exploit max_prob: mean={np.mean(exploit_probs):.3f} "
|
| 204 |
+
f"min={np.min(exploit_probs):.3f} max={np.max(exploit_probs):.3f}")
|
| 205 |
+
print(f" Benign max_prob: mean={np.mean(benign_probs):.3f} "
|
| 206 |
+
f"min={np.min(benign_probs):.3f} max={np.max(benign_probs):.3f}")
|
| 207 |
+
|
| 208 |
+
all_results[test_num] = {
|
| 209 |
+
"name": name, "tp": tp, "fp": fp, "fn": fn, "tn": tn,
|
| 210 |
+
"prec": prec, "rec": rec, "f1": f1,
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# Summary
|
| 214 |
+
print("\n" + "=" * 80)
|
| 215 |
+
print("SUMMARY")
|
| 216 |
+
print("=" * 80)
|
| 217 |
+
total_tp = sum(r["tp"] for r in all_results.values())
|
| 218 |
+
total_fp = sum(r["fp"] for r in all_results.values())
|
| 219 |
+
total_fn = sum(r["fn"] for r in all_results.values())
|
| 220 |
+
total_tn = sum(r["tn"] for r in all_results.values())
|
| 221 |
+
|
| 222 |
+
for num, r in all_results.items():
|
| 223 |
+
status = "DETECTED" if r["rec"] >= 0.5 else "MISSED"
|
| 224 |
+
print(f" Test {num}: {r['name']:45s} | rec={r['rec']:.2f} f1={r['f1']:.3f} [{status}]")
|
| 225 |
+
|
| 226 |
+
detected = sum(1 for r in all_results.values() if r["rec"] >= 0.5)
|
| 227 |
+
print(f"\n CVE patterns detected: {detected}/5")
|
| 228 |
+
overall_prec = total_tp / max(total_tp + total_fp, 1)
|
| 229 |
+
overall_rec = total_tp / max(total_tp + total_fn, 1)
|
| 230 |
+
overall_f1 = 2 * overall_prec * overall_rec / max(overall_prec + overall_rec, 1e-8)
|
| 231 |
+
print(f" Overall: prec={overall_prec:.3f} rec={overall_rec:.3f} f1={overall_f1:.3f}")
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
main()
|
dataset/dataset_gen.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dataset_gen.py - Convert heap dump JSONL files into TRM-ready grid arrays.
|
| 3 |
+
|
| 4 |
+
Grid layout: 32 rows (chunks) x 16 columns (metadata fields)
|
| 5 |
+
Each cell is an integer token (vocab size 64).
|
| 6 |
+
|
| 7 |
+
Column layout:
|
| 8 |
+
0: chunk_state (0=pad, 1=alloc, 2=freed)
|
| 9 |
+
1: size_class (chunk_size >> 4, clamped to 0-63)
|
| 10 |
+
2: flag_P (PREV_INUSE)
|
| 11 |
+
3: flag_M (IS_MMAPPED)
|
| 12 |
+
4: flag_A (NON_MAIN_ARENA)
|
| 13 |
+
5: fd_target (chunk index fd points to; 0=null, 33=external, 1-32=chunk idx+1)
|
| 14 |
+
6: bk_target (same encoding)
|
| 15 |
+
7: alloc_order (clamped to 0-63)
|
| 16 |
+
8: free_order (0 if not freed, else clamped to 1-63)
|
| 17 |
+
9: is_target (1 if this chunk was target of the operation)
|
| 18 |
+
10: is_double_freed
|
| 19 |
+
11-14: data_bytes (first 4 bytes of user data, each as nibble pair high/low, quantized)
|
| 20 |
+
15: req_size_class (req_size >> 3, clamped to 0-63)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import numpy as np
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import List, Dict, Tuple, Optional
|
| 27 |
+
|
| 28 |
+
GRID_ROWS = 32
|
| 29 |
+
GRID_COLS = 16
|
| 30 |
+
VOCAB_SIZE = 64
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def clamp(val: int, lo: int = 0, hi: int = 63) -> int:
|
| 34 |
+
return max(lo, min(hi, val))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def encode_chunk(chunk: dict, chunk_count: int) -> List[int]:
|
| 38 |
+
"""Encode a single chunk dict into a row of GRID_COLS integers."""
|
| 39 |
+
row = [0] * GRID_COLS
|
| 40 |
+
|
| 41 |
+
# Col 0: state
|
| 42 |
+
row[0] = clamp(chunk.get("state", 0), 0, 2)
|
| 43 |
+
|
| 44 |
+
# Col 1: size class
|
| 45 |
+
chunk_size = chunk.get("chunk_size", 0)
|
| 46 |
+
row[1] = clamp(chunk_size >> 4, 0, 63)
|
| 47 |
+
|
| 48 |
+
# Col 2-4: flags
|
| 49 |
+
row[2] = chunk.get("flag_p", 0)
|
| 50 |
+
row[3] = chunk.get("flag_m", 0)
|
| 51 |
+
row[4] = chunk.get("flag_a", 0)
|
| 52 |
+
|
| 53 |
+
# Col 5-6: fd/bk target indices
|
| 54 |
+
fd_idx = chunk.get("fd_idx", -1)
|
| 55 |
+
if fd_idx == -1: # null
|
| 56 |
+
row[5] = 0
|
| 57 |
+
elif fd_idx == -2: # external address
|
| 58 |
+
row[5] = 33
|
| 59 |
+
else:
|
| 60 |
+
row[5] = clamp(fd_idx + 1, 1, 32)
|
| 61 |
+
|
| 62 |
+
bk_idx = chunk.get("bk_idx", -1)
|
| 63 |
+
if bk_idx == -1:
|
| 64 |
+
row[6] = 0
|
| 65 |
+
elif bk_idx == -2:
|
| 66 |
+
row[6] = 33
|
| 67 |
+
else:
|
| 68 |
+
row[6] = clamp(bk_idx + 1, 1, 32)
|
| 69 |
+
|
| 70 |
+
# Col 7-8: ordering
|
| 71 |
+
row[7] = clamp(chunk.get("alloc_order", 0), 0, 63)
|
| 72 |
+
row[8] = clamp(chunk.get("free_order", 0), 0, 63)
|
| 73 |
+
|
| 74 |
+
# Col 9-10: flags
|
| 75 |
+
row[9] = chunk.get("is_target", 0)
|
| 76 |
+
row[10] = chunk.get("is_double_freed", 0)
|
| 77 |
+
|
| 78 |
+
# Col 11-14: first 4 data bytes
|
| 79 |
+
data_hex = chunk.get("data_hex", "")
|
| 80 |
+
for i in range(4):
|
| 81 |
+
if i * 2 + 1 < len(data_hex):
|
| 82 |
+
byte_val = int(data_hex[i*2:i*2+2], 16)
|
| 83 |
+
row[11 + i] = clamp(byte_val >> 2, 0, 63) # quantize to 6 bits
|
| 84 |
+
else:
|
| 85 |
+
row[11 + i] = 0
|
| 86 |
+
|
| 87 |
+
# Col 15: requested size class
|
| 88 |
+
req_size = chunk.get("req_size", 0)
|
| 89 |
+
row[15] = clamp(req_size >> 3, 0, 63)
|
| 90 |
+
|
| 91 |
+
return row
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def state_to_grid(state: dict) -> np.ndarray:
|
| 95 |
+
"""Convert a single heap state (one JSONL line) to a 32x16 grid."""
|
| 96 |
+
grid = np.zeros((GRID_ROWS, GRID_COLS), dtype=np.int64)
|
| 97 |
+
chunks = state.get("chunks", [])
|
| 98 |
+
|
| 99 |
+
for i, chunk in enumerate(chunks[:GRID_ROWS]):
|
| 100 |
+
grid[i] = encode_chunk(chunk, len(chunks))
|
| 101 |
+
|
| 102 |
+
return grid
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# --- Operation encoding ---
|
| 106 |
+
OP_MAP = {
|
| 107 |
+
"malloc": 1,
|
| 108 |
+
"calloc": 2,
|
| 109 |
+
"free": 3,
|
| 110 |
+
"realloc": 4,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def encode_operation(state: dict) -> int:
|
| 115 |
+
"""Encode the operation type as an integer."""
|
| 116 |
+
return OP_MAP.get(state.get("operation", ""), 0)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def load_dump(path: Path) -> List[dict]:
|
| 120 |
+
"""Load a JSONL heap dump file."""
|
| 121 |
+
states = []
|
| 122 |
+
with open(path) as f:
|
| 123 |
+
for line in f:
|
| 124 |
+
line = line.strip()
|
| 125 |
+
if line:
|
| 126 |
+
states.append(json.loads(line))
|
| 127 |
+
return states
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def label_sequence(states: List[dict], technique: str = "default") -> np.ndarray:
|
| 131 |
+
"""
|
| 132 |
+
Label each state in a sequence.
|
| 133 |
+
|
| 134 |
+
Strategy: the last `tail_len` states before the end are labeled as
|
| 135 |
+
'approaching exploit primitive' (1). Everything before is (0).
|
| 136 |
+
This is a simple heuristic -- the final states in how2heap examples
|
| 137 |
+
are where the exploit primitive is achieved.
|
| 138 |
+
|
| 139 |
+
We also label any state containing a double-free as 1.
|
| 140 |
+
"""
|
| 141 |
+
n = len(states)
|
| 142 |
+
labels = np.zeros(n, dtype=np.int64)
|
| 143 |
+
|
| 144 |
+
# Last 20% of states are "near exploit"
|
| 145 |
+
tail_start = max(0, int(n * 0.8))
|
| 146 |
+
labels[tail_start:] = 1
|
| 147 |
+
|
| 148 |
+
# Also label any state with double-freed chunks
|
| 149 |
+
for i, state in enumerate(states):
|
| 150 |
+
for chunk in state.get("chunks", []):
|
| 151 |
+
if chunk.get("is_double_freed", 0):
|
| 152 |
+
labels[i] = 1
|
| 153 |
+
|
| 154 |
+
return labels
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def process_dump_file(
|
| 158 |
+
dump_path: Path,
|
| 159 |
+
technique: str = "unknown"
|
| 160 |
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 161 |
+
"""
|
| 162 |
+
Process a single dump file into grids, labels, and operation codes.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
grids: (N, 32, 16) int64 array
|
| 166 |
+
labels: (N,) int64 array
|
| 167 |
+
ops: (N,) int64 array
|
| 168 |
+
"""
|
| 169 |
+
states = load_dump(dump_path)
|
| 170 |
+
if not states:
|
| 171 |
+
return (np.zeros((0, GRID_ROWS, GRID_COLS), dtype=np.int64),
|
| 172 |
+
np.zeros(0, dtype=np.int64),
|
| 173 |
+
np.zeros(0, dtype=np.int64))
|
| 174 |
+
|
| 175 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 176 |
+
labels = label_sequence(states, technique)
|
| 177 |
+
ops = np.array([encode_operation(s) for s in states], dtype=np.int64)
|
| 178 |
+
|
| 179 |
+
return grids, labels, ops
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def build_dataset(
|
| 183 |
+
dump_dir: Path,
|
| 184 |
+
output_dir: Path,
|
| 185 |
+
train_techniques: List[str],
|
| 186 |
+
test_techniques: List[str],
|
| 187 |
+
):
|
| 188 |
+
"""
|
| 189 |
+
Build train/test numpy datasets from a directory of dump files.
|
| 190 |
+
|
| 191 |
+
Expects dump files named like: <technique>.jsonl
|
| 192 |
+
"""
|
| 193 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 194 |
+
|
| 195 |
+
def collect(techniques: List[str], split: str):
|
| 196 |
+
all_grids = []
|
| 197 |
+
all_labels = []
|
| 198 |
+
all_ops = []
|
| 199 |
+
|
| 200 |
+
for tech in techniques:
|
| 201 |
+
dump_path = dump_dir / f"{tech}.jsonl"
|
| 202 |
+
if not dump_path.exists():
|
| 203 |
+
print(f" [SKIP] {dump_path} not found")
|
| 204 |
+
continue
|
| 205 |
+
|
| 206 |
+
grids, labels, ops = process_dump_file(dump_path, tech)
|
| 207 |
+
print(f" [{split}] {tech}: {len(grids)} states, "
|
| 208 |
+
f"{labels.sum()} positive")
|
| 209 |
+
all_grids.append(grids)
|
| 210 |
+
all_labels.append(labels)
|
| 211 |
+
all_ops.append(ops)
|
| 212 |
+
|
| 213 |
+
if all_grids:
|
| 214 |
+
X = np.concatenate(all_grids)
|
| 215 |
+
y = np.concatenate(all_labels)
|
| 216 |
+
o = np.concatenate(all_ops)
|
| 217 |
+
else:
|
| 218 |
+
X = np.zeros((0, GRID_ROWS, GRID_COLS), dtype=np.int64)
|
| 219 |
+
y = np.zeros(0, dtype=np.int64)
|
| 220 |
+
o = np.zeros(0, dtype=np.int64)
|
| 221 |
+
|
| 222 |
+
np.save(output_dir / f"X_{split}.npy", X)
|
| 223 |
+
np.save(output_dir / f"y_{split}.npy", y)
|
| 224 |
+
np.save(output_dir / f"ops_{split}.npy", o)
|
| 225 |
+
print(f" {split}: {X.shape[0]} total states saved")
|
| 226 |
+
return X, y
|
| 227 |
+
|
| 228 |
+
print("Building training set...")
|
| 229 |
+
collect(train_techniques, "train")
|
| 230 |
+
print("Building test set...")
|
| 231 |
+
collect(test_techniques, "test")
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
import argparse
|
| 236 |
+
parser = argparse.ArgumentParser()
|
| 237 |
+
parser.add_argument("--dump-dir", type=Path, default=Path("data/dumps"))
|
| 238 |
+
parser.add_argument("--output-dir", type=Path, default=Path("data/processed"))
|
| 239 |
+
parser.add_argument("--train", nargs="+", default=[
|
| 240 |
+
"fastbin_dup", "fastbin_dup_into_stack", "tcache_poisoning",
|
| 241 |
+
"house_of_spirit", "unsafe_unlink", "tcache_house_of_spirit",
|
| 242 |
+
])
|
| 243 |
+
parser.add_argument("--test", nargs="+", default=[
|
| 244 |
+
"fastbin_dup_consolidate", "house_of_botcake", "overlapping_chunks",
|
| 245 |
+
])
|
| 246 |
+
args = parser.parse_args()
|
| 247 |
+
build_dataset(args.dump_dir, args.output_dir, args.train, args.test)
|
docker/cross_glibc.sh
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# cross_glibc.sh - Build and test across glibc versions via Docker
|
| 3 |
+
set +e # don't exit on individual technique failures
|
| 4 |
+
|
| 5 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 6 |
+
DUMP_DIR="$ROOT/data/dumps_glibc"
|
| 7 |
+
mkdir -p "$DUMP_DIR"
|
| 8 |
+
|
| 9 |
+
declare -A VERSIONS
|
| 10 |
+
VERSIONS[bionic]="2.27"
|
| 11 |
+
VERSIONS[focal]="2.31"
|
| 12 |
+
VERSIONS[jammy]="2.35"
|
| 13 |
+
|
| 14 |
+
TECHNIQUES=(
|
| 15 |
+
house_of_botcake house_of_einherjar house_of_force house_of_gods
|
| 16 |
+
house_of_lore house_of_mind_fastbin house_of_orange house_of_roman
|
| 17 |
+
house_of_spirit house_of_storm house_of_tangerine house_of_water
|
| 18 |
+
fastbin_dup fastbin_dup_consolidate fastbin_dup_into_stack
|
| 19 |
+
fastbin_reverse_into_tcache tcache_poisoning tcache_house_of_spirit
|
| 20 |
+
tcache_stashing_unlink_attack unsafe_unlink overlapping_chunks
|
| 21 |
+
poison_null_byte large_bin_attack mmap_overlapping_chunks
|
| 22 |
+
decrypt_safe_linking safe_link_double_protect sysmalloc_int_free
|
| 23 |
+
tcache_metadata_poisoning tcache_relative_write house_of_io
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
for ubuntu_ver in bionic focal jammy; do
|
| 27 |
+
glibc="${VERSIONS[$ubuntu_ver]}"
|
| 28 |
+
tag="heap-trm-${ubuntu_ver}"
|
| 29 |
+
|
| 30 |
+
echo ""
|
| 31 |
+
echo "=========================================="
|
| 32 |
+
echo " Ubuntu ${ubuntu_ver} (glibc ${glibc})"
|
| 33 |
+
echo "=========================================="
|
| 34 |
+
|
| 35 |
+
# Build image
|
| 36 |
+
docker build --network=host -t "$tag" -f - "$ROOT" << DOCKERFILE 2>/dev/null
|
| 37 |
+
FROM ubuntu:${ubuntu_ver}
|
| 38 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 39 |
+
RUN apt-get update && apt-get install -y gcc make && rm -rf /var/lib/apt/lists/*
|
| 40 |
+
WORKDIR /work
|
| 41 |
+
COPY harness/heapgrid_harness.c harness/Makefile harness/
|
| 42 |
+
COPY how2heap/ how2heap/
|
| 43 |
+
RUN make -C harness/
|
| 44 |
+
DOCKERFILE
|
| 45 |
+
|
| 46 |
+
if [ $? -ne 0 ]; then
|
| 47 |
+
echo " BUILD FAILED"
|
| 48 |
+
continue
|
| 49 |
+
fi
|
| 50 |
+
|
| 51 |
+
# Verify glibc version
|
| 52 |
+
actual=$(docker run --rm "$tag" ldd --version 2>&1 | head -1 | grep -oP '\d+\.\d+$')
|
| 53 |
+
echo " Actual glibc: ${actual}"
|
| 54 |
+
|
| 55 |
+
ok=0
|
| 56 |
+
total=0
|
| 57 |
+
for tech in "${TECHNIQUES[@]}"; do
|
| 58 |
+
# Find the matching how2heap source for this glibc
|
| 59 |
+
how2heap_dir="glibc_${glibc}"
|
| 60 |
+
if [ ! -f "$ROOT/how2heap/${how2heap_dir}/${tech}.c" ]; then
|
| 61 |
+
# Try nearby versions
|
| 62 |
+
for alt in ${glibc} 2.27 2.31 2.35 2.23 2.24 2.33 2.32 2.34; do
|
| 63 |
+
if [ -f "$ROOT/how2heap/glibc_${alt}/${tech}.c" ]; then
|
| 64 |
+
how2heap_dir="glibc_${alt}"
|
| 65 |
+
break
|
| 66 |
+
fi
|
| 67 |
+
done
|
| 68 |
+
fi
|
| 69 |
+
|
| 70 |
+
if [ ! -f "$ROOT/how2heap/${how2heap_dir}/${tech}.c" ]; then
|
| 71 |
+
continue
|
| 72 |
+
fi
|
| 73 |
+
|
| 74 |
+
((total++))
|
| 75 |
+
dump_file="${tech}_glibc${glibc//.}.jsonl"
|
| 76 |
+
|
| 77 |
+
docker run --rm --network=none \
|
| 78 |
+
-v "$DUMP_DIR:/dumps" \
|
| 79 |
+
"$tag" bash -c "
|
| 80 |
+
gcc -o /tmp/prog /work/how2heap/${how2heap_dir}/${tech}.c \
|
| 81 |
+
-std=c99 -g -O0 -Wno-all -lpthread 2>/dev/null || exit 1
|
| 82 |
+
HEAPGRID_OUT=/dumps/${dump_file} \
|
| 83 |
+
LD_PRELOAD=/work/harness/heapgrid_harness.so \
|
| 84 |
+
timeout 5 /tmp/prog >/dev/null 2>&1
|
| 85 |
+
exit 0
|
| 86 |
+
" 2>/dev/null
|
| 87 |
+
|
| 88 |
+
if [ -s "$DUMP_DIR/$dump_file" ]; then
|
| 89 |
+
states=$(wc -l < "$DUMP_DIR/$dump_file")
|
| 90 |
+
echo " [OK] ${tech}: ${states} states"
|
| 91 |
+
((ok++))
|
| 92 |
+
fi
|
| 93 |
+
done
|
| 94 |
+
echo " --- ${ok}/${total} techniques dumped ---"
|
| 95 |
+
done
|
| 96 |
+
|
| 97 |
+
echo ""
|
| 98 |
+
echo "=========================================="
|
| 99 |
+
echo " Summary"
|
| 100 |
+
echo "=========================================="
|
| 101 |
+
echo "Total dump files: $(ls $DUMP_DIR/*.jsonl 2>/dev/null | wc -l)"
|
| 102 |
+
echo "Total states: $(cat $DUMP_DIR/*.jsonl 2>/dev/null | wc -l)"
|
docker/run_glibc_tests.sh
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# run_glibc_tests.sh - Run how2heap techniques across multiple glibc versions via Docker
|
| 3 |
+
#
|
| 4 |
+
# Ubuntu version -> glibc version mapping:
|
| 5 |
+
# 16.04 (xenial) -> glibc 2.23
|
| 6 |
+
# 18.04 (bionic) -> glibc 2.27
|
| 7 |
+
# 20.04 (focal) -> glibc 2.31
|
| 8 |
+
# 22.04 (jammy) -> glibc 2.35
|
| 9 |
+
# 24.04 (noble) -> glibc 2.39 (our native)
|
| 10 |
+
|
| 11 |
+
set -e
|
| 12 |
+
|
| 13 |
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 14 |
+
DUMP_DIR="$ROOT/data/dumps_glibc"
|
| 15 |
+
mkdir -p "$DUMP_DIR"
|
| 16 |
+
|
| 17 |
+
# Map Ubuntu versions to glibc and available how2heap directories
|
| 18 |
+
declare -A UBUNTU_GLIBC
|
| 19 |
+
UBUNTU_GLIBC[xenial]="2.23"
|
| 20 |
+
UBUNTU_GLIBC[bionic]="2.27"
|
| 21 |
+
UBUNTU_GLIBC[focal]="2.31"
|
| 22 |
+
UBUNTU_GLIBC[jammy]="2.35"
|
| 23 |
+
|
| 24 |
+
# Build a Docker image with our harness for each Ubuntu version
|
| 25 |
+
build_image() {
|
| 26 |
+
local ubuntu_ver=$1
|
| 27 |
+
local tag="heap-trm-${ubuntu_ver}"
|
| 28 |
+
|
| 29 |
+
echo "=== Building ${tag} ==="
|
| 30 |
+
docker build -t "$tag" -f - "$ROOT" << DOCKERFILE
|
| 31 |
+
FROM ubuntu:${ubuntu_ver}
|
| 32 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 33 |
+
RUN apt-get update && apt-get install -y gcc make && rm -rf /var/lib/apt/lists/*
|
| 34 |
+
WORKDIR /work
|
| 35 |
+
COPY harness/heapgrid_harness.c harness/Makefile harness/
|
| 36 |
+
COPY how2heap/ how2heap/
|
| 37 |
+
RUN make -C harness/
|
| 38 |
+
DOCKERFILE
|
| 39 |
+
echo " Built: ${tag}"
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Run a technique inside a container
|
| 43 |
+
run_technique() {
|
| 44 |
+
local ubuntu_ver=$1
|
| 45 |
+
local glibc_ver=$2
|
| 46 |
+
local technique=$3
|
| 47 |
+
local tag="heap-trm-${ubuntu_ver}"
|
| 48 |
+
local how2heap_dir="glibc_${glibc_ver}"
|
| 49 |
+
|
| 50 |
+
# Check if source exists for this glibc version
|
| 51 |
+
if [ ! -f "$ROOT/how2heap/${how2heap_dir}/${technique}.c" ]; then
|
| 52 |
+
return 1
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
local dump_file="${technique}_glibc${glibc_ver//.}.jsonl"
|
| 56 |
+
|
| 57 |
+
docker run --rm -v "$DUMP_DIR:/dumps" "$tag" bash -c "
|
| 58 |
+
cd /work
|
| 59 |
+
gcc -o /tmp/${technique} how2heap/${how2heap_dir}/${technique}.c \
|
| 60 |
+
-std=c99 -g -O0 -Wno-all -lpthread 2>/dev/null || exit 1
|
| 61 |
+
HEAPGRID_OUT=/dumps/${dump_file} \
|
| 62 |
+
LD_PRELOAD=/work/harness/heapgrid_harness.so \
|
| 63 |
+
timeout 5 /tmp/${technique} >/dev/null 2>&1 || true
|
| 64 |
+
" 2>/dev/null
|
| 65 |
+
|
| 66 |
+
if [ -s "$DUMP_DIR/$dump_file" ]; then
|
| 67 |
+
local states=$(wc -l < "$DUMP_DIR/$dump_file")
|
| 68 |
+
echo " [OK] ${technique} (glibc ${glibc_ver}): ${states} states"
|
| 69 |
+
return 0
|
| 70 |
+
else
|
| 71 |
+
return 1
|
| 72 |
+
fi
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# All house techniques
|
| 76 |
+
TECHNIQUES=(
|
| 77 |
+
house_of_botcake house_of_einherjar house_of_force house_of_gods
|
| 78 |
+
house_of_io house_of_lore house_of_mind_fastbin house_of_orange
|
| 79 |
+
house_of_roman house_of_spirit house_of_storm house_of_tangerine
|
| 80 |
+
house_of_water
|
| 81 |
+
fastbin_dup fastbin_dup_consolidate fastbin_dup_into_stack
|
| 82 |
+
fastbin_reverse_into_tcache tcache_poisoning tcache_house_of_spirit
|
| 83 |
+
unsafe_unlink overlapping_chunks poison_null_byte large_bin_attack
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
echo "============================================"
|
| 87 |
+
echo " HeapTRM Cross-glibc Testing"
|
| 88 |
+
echo "============================================"
|
| 89 |
+
|
| 90 |
+
# Build images
|
| 91 |
+
for ubuntu_ver in xenial bionic focal jammy; do
|
| 92 |
+
build_image "$ubuntu_ver"
|
| 93 |
+
done
|
| 94 |
+
|
| 95 |
+
# Run all techniques across all versions
|
| 96 |
+
echo ""
|
| 97 |
+
echo "=== Running techniques ==="
|
| 98 |
+
for ubuntu_ver in xenial bionic focal jammy; do
|
| 99 |
+
glibc_ver="${UBUNTU_GLIBC[$ubuntu_ver]}"
|
| 100 |
+
echo ""
|
| 101 |
+
echo "--- Ubuntu ${ubuntu_ver} (glibc ${glibc_ver}) ---"
|
| 102 |
+
|
| 103 |
+
ok=0
|
| 104 |
+
fail=0
|
| 105 |
+
for tech in "${TECHNIQUES[@]}"; do
|
| 106 |
+
if run_technique "$ubuntu_ver" "$glibc_ver" "$tech"; then
|
| 107 |
+
((ok++))
|
| 108 |
+
else
|
| 109 |
+
((fail++))
|
| 110 |
+
fi
|
| 111 |
+
done
|
| 112 |
+
echo " Total: ${ok} ok, ${fail} failed/missing"
|
| 113 |
+
done
|
| 114 |
+
|
| 115 |
+
echo ""
|
| 116 |
+
echo "=== Done ==="
|
| 117 |
+
echo "Dumps in: $DUMP_DIR"
|
| 118 |
+
echo "Total dump files: $(ls $DUMP_DIR/*.jsonl 2>/dev/null | wc -l)"
|
| 119 |
+
echo "Total states: $(cat $DUMP_DIR/*.jsonl 2>/dev/null | wc -l)"
|
harness/Makefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CC = gcc
|
| 2 |
+
CFLAGS = -shared -fPIC -O2 -Wall
|
| 3 |
+
LDFLAGS = -ldl -pthread
|
| 4 |
+
|
| 5 |
+
all: heapgrid_harness.so
|
| 6 |
+
|
| 7 |
+
heapgrid_harness.so: heapgrid_harness.c
|
| 8 |
+
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
| 9 |
+
|
| 10 |
+
clean:
|
| 11 |
+
rm -f heapgrid_harness.so
|
| 12 |
+
|
| 13 |
+
.PHONY: all clean
|
harness/heapgrid_harness.c
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* heapgrid_harness.c - LD_PRELOAD heap state dumper
|
| 3 |
+
*
|
| 4 |
+
* Intercepts malloc/free/calloc/realloc and dumps heap chunk state
|
| 5 |
+
* as JSON after each operation. Output goes to file descriptor specified
|
| 6 |
+
* by HEAPGRID_FD env var (default: writes to HEAPGRID_OUT file, default "heap_dump.jsonl").
|
| 7 |
+
*
|
| 8 |
+
* Build: gcc -shared -fPIC -o heapgrid_harness.so heapgrid_harness.c -ldl -pthread
|
| 9 |
+
* Usage: LD_PRELOAD=./heapgrid_harness.so ./target_binary
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
#define _GNU_SOURCE
|
| 13 |
+
#include <dlfcn.h>
|
| 14 |
+
#include <stdio.h>
|
| 15 |
+
#include <stdlib.h>
|
| 16 |
+
#include <string.h>
|
| 17 |
+
#include <unistd.h>
|
| 18 |
+
#include <stdint.h>
|
| 19 |
+
#include <fcntl.h>
|
| 20 |
+
#include <pthread.h>
|
| 21 |
+
|
| 22 |
+
/* --- Configuration --- */
|
| 23 |
+
#define MAX_CHUNKS 128
|
| 24 |
+
#define DATA_PEEK_BYTES 32
|
| 25 |
+
#define DUMP_BUF_SIZE (1024 * 64)
|
| 26 |
+
|
| 27 |
+
/* --- Chunk tracking --- */
|
| 28 |
+
typedef struct {
|
| 29 |
+
void *user_ptr; /* pointer returned to user */
|
| 30 |
+
size_t req_size; /* requested size */
|
| 31 |
+
size_t chunk_size; /* actual chunk size from header */
|
| 32 |
+
int state; /* 0=unused, 1=allocated, 2=freed */
|
| 33 |
+
int alloc_order; /* sequence number of allocation */
|
| 34 |
+
int free_order; /* sequence number of free (0 if not freed) */
|
| 35 |
+
} tracked_chunk_t;
|
| 36 |
+
|
| 37 |
+
static tracked_chunk_t g_chunks[MAX_CHUNKS];
|
| 38 |
+
static int g_chunk_count = 0;
|
| 39 |
+
static int g_step = 0;
|
| 40 |
+
static int g_alloc_seq = 0;
|
| 41 |
+
static int g_free_seq = 0;
|
| 42 |
+
static int g_out_fd = -1;
|
| 43 |
+
static int g_initialized = 0;
|
| 44 |
+
static __thread int g_in_hook = 0; /* reentrance guard */
|
| 45 |
+
|
| 46 |
+
/* Real function pointers */
|
| 47 |
+
static void *(*real_malloc)(size_t) = NULL;
|
| 48 |
+
static void (*real_free)(void *) = NULL;
|
| 49 |
+
static void *(*real_calloc)(size_t, size_t) = NULL;
|
| 50 |
+
static void *(*real_realloc)(void *, size_t) = NULL;
|
| 51 |
+
|
| 52 |
+
/* Early calloc buffer for dlsym which may call calloc */
|
| 53 |
+
static char early_buf[4096];
|
| 54 |
+
static int early_buf_used = 0;
|
| 55 |
+
|
| 56 |
+
static void init_hooks(void) {
|
| 57 |
+
if (g_initialized) return;
|
| 58 |
+
g_initialized = 1;
|
| 59 |
+
|
| 60 |
+
real_malloc = dlsym(RTLD_NEXT, "malloc");
|
| 61 |
+
real_free = dlsym(RTLD_NEXT, "free");
|
| 62 |
+
real_calloc = dlsym(RTLD_NEXT, "calloc");
|
| 63 |
+
real_realloc = dlsym(RTLD_NEXT, "realloc");
|
| 64 |
+
|
| 65 |
+
const char *outfile = getenv("HEAPGRID_OUT");
|
| 66 |
+
if (!outfile) outfile = "heap_dump.jsonl";
|
| 67 |
+
g_out_fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
| 68 |
+
if (g_out_fd < 0) g_out_fd = STDERR_FILENO;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* --- Chunk tracking helpers --- */
|
| 72 |
+
|
| 73 |
+
static int find_chunk(void *ptr) {
|
| 74 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 75 |
+
if (g_chunks[i].user_ptr == ptr && g_chunks[i].state != 0)
|
| 76 |
+
return i;
|
| 77 |
+
}
|
| 78 |
+
return -1;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
static int find_allocated_chunk(void *ptr) {
|
| 82 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 83 |
+
if (g_chunks[i].user_ptr == ptr && g_chunks[i].state == 1)
|
| 84 |
+
return i;
|
| 85 |
+
}
|
| 86 |
+
return -1;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
static int add_chunk(void *ptr, size_t req_size) {
|
| 90 |
+
/* Try to reuse a slot */
|
| 91 |
+
int slot = -1;
|
| 92 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 93 |
+
if (g_chunks[i].state == 0) {
|
| 94 |
+
slot = i;
|
| 95 |
+
break;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
if (slot < 0) {
|
| 99 |
+
if (g_chunk_count >= MAX_CHUNKS) return -1;
|
| 100 |
+
slot = g_chunk_count++;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
g_chunks[slot].user_ptr = ptr;
|
| 104 |
+
g_chunks[slot].req_size = req_size;
|
| 105 |
+
g_chunks[slot].state = 1;
|
| 106 |
+
g_chunks[slot].alloc_order = ++g_alloc_seq;
|
| 107 |
+
g_chunks[slot].free_order = 0;
|
| 108 |
+
|
| 109 |
+
/* Read actual chunk size from malloc header */
|
| 110 |
+
size_t *chunk_hdr = (size_t *)((char *)ptr - 2 * sizeof(size_t));
|
| 111 |
+
g_chunks[slot].chunk_size = chunk_hdr[1] & ~0x7UL; /* mask out flags */
|
| 112 |
+
|
| 113 |
+
return slot;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
static void mark_freed(int idx) {
|
| 117 |
+
g_chunks[idx].state = 2;
|
| 118 |
+
g_chunks[idx].free_order = ++g_free_seq;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* --- Read chunk header safely --- */
|
| 122 |
+
typedef struct {
|
| 123 |
+
size_t prev_size;
|
| 124 |
+
size_t size_raw;
|
| 125 |
+
size_t size; /* without flags */
|
| 126 |
+
int flag_p; /* PREV_INUSE */
|
| 127 |
+
int flag_m; /* IS_MMAPPED */
|
| 128 |
+
int flag_a; /* NON_MAIN_ARENA */
|
| 129 |
+
uint64_t fd; /* first 8 bytes of user data (fd for freed chunks) */
|
| 130 |
+
uint64_t bk; /* next 8 bytes (bk for freed chunks) */
|
| 131 |
+
uint8_t data[DATA_PEEK_BYTES];
|
| 132 |
+
} chunk_info_t;
|
| 133 |
+
|
| 134 |
+
static int read_chunk_info(void *user_ptr, size_t known_size, chunk_info_t *info) {
|
| 135 |
+
size_t *hdr = (size_t *)((char *)user_ptr - 2 * sizeof(size_t));
|
| 136 |
+
|
| 137 |
+
info->prev_size = hdr[0];
|
| 138 |
+
info->size_raw = hdr[1];
|
| 139 |
+
info->size = hdr[1] & ~0x7UL;
|
| 140 |
+
info->flag_p = (hdr[1] & 0x1) ? 1 : 0;
|
| 141 |
+
info->flag_m = (hdr[1] & 0x2) ? 1 : 0;
|
| 142 |
+
info->flag_a = (hdr[1] & 0x4) ? 1 : 0;
|
| 143 |
+
|
| 144 |
+
/* Read user data (contains fd/bk for freed chunks) */
|
| 145 |
+
memset(info->data, 0, DATA_PEEK_BYTES);
|
| 146 |
+
memcpy(info->data, user_ptr,
|
| 147 |
+
known_size < DATA_PEEK_BYTES ? known_size : DATA_PEEK_BYTES);
|
| 148 |
+
|
| 149 |
+
info->fd = 0;
|
| 150 |
+
info->bk = 0;
|
| 151 |
+
if (known_size >= 8)
|
| 152 |
+
memcpy(&info->fd, user_ptr, 8);
|
| 153 |
+
if (known_size >= 16)
|
| 154 |
+
memcpy(&info->bk, (char *)user_ptr + 8, 8);
|
| 155 |
+
|
| 156 |
+
return 0;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* --- Resolve fd pointer to chunk index --- */
|
| 160 |
+
static int resolve_ptr_to_index(uint64_t ptr_val) {
|
| 161 |
+
if (ptr_val == 0) return -1;
|
| 162 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 163 |
+
if (g_chunks[i].state != 0 &&
|
| 164 |
+
(uint64_t)g_chunks[i].user_ptr == ptr_val)
|
| 165 |
+
return i;
|
| 166 |
+
}
|
| 167 |
+
return -2; /* external address */
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* --- Detect double frees --- */
|
| 171 |
+
static int is_double_freed(int idx) {
|
| 172 |
+
void *ptr = g_chunks[idx].user_ptr;
|
| 173 |
+
int count = 0;
|
| 174 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 175 |
+
if (g_chunks[i].user_ptr == ptr && g_chunks[i].state == 2)
|
| 176 |
+
count++;
|
| 177 |
+
}
|
| 178 |
+
return count > 1 ? 1 : 0;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* --- Dump heap state as JSON --- */
|
| 182 |
+
static void dump_state(const char *op, void *target_ptr, size_t size) {
|
| 183 |
+
static char buf[DUMP_BUF_SIZE];
|
| 184 |
+
int pos = 0;
|
| 185 |
+
|
| 186 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos,
|
| 187 |
+
"{\"step\":%d,\"operation\":\"%s\",\"target_addr\":\"%p\",\"target_size\":%zu,\"chunks\":[",
|
| 188 |
+
g_step, op, target_ptr, size);
|
| 189 |
+
|
| 190 |
+
int first = 1;
|
| 191 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 192 |
+
if (g_chunks[i].state == 0) continue;
|
| 193 |
+
|
| 194 |
+
chunk_info_t ci;
|
| 195 |
+
read_chunk_info(g_chunks[i].user_ptr, g_chunks[i].req_size, &ci);
|
| 196 |
+
|
| 197 |
+
int fd_idx = -1;
|
| 198 |
+
int bk_idx = -1;
|
| 199 |
+
if (g_chunks[i].state == 2) {
|
| 200 |
+
fd_idx = resolve_ptr_to_index(ci.fd);
|
| 201 |
+
bk_idx = resolve_ptr_to_index(ci.bk);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
int is_target = (g_chunks[i].user_ptr == target_ptr) ? 1 : 0;
|
| 205 |
+
int dbl_free = is_double_freed(i);
|
| 206 |
+
|
| 207 |
+
if (!first) buf[pos++] = ',';
|
| 208 |
+
first = 0;
|
| 209 |
+
|
| 210 |
+
/* Data bytes as hex */
|
| 211 |
+
char data_hex[DATA_PEEK_BYTES * 2 + 1];
|
| 212 |
+
int dlen = g_chunks[i].req_size < DATA_PEEK_BYTES ?
|
| 213 |
+
g_chunks[i].req_size : DATA_PEEK_BYTES;
|
| 214 |
+
for (int j = 0; j < dlen; j++)
|
| 215 |
+
snprintf(data_hex + j * 2, 3, "%02x", ci.data[j]);
|
| 216 |
+
data_hex[dlen * 2] = '\0';
|
| 217 |
+
|
| 218 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos,
|
| 219 |
+
"{\"idx\":%d,\"addr\":\"%p\",\"state\":%d,"
|
| 220 |
+
"\"req_size\":%zu,\"chunk_size\":%zu,"
|
| 221 |
+
"\"prev_size\":%zu,\"size_raw\":%zu,"
|
| 222 |
+
"\"flag_p\":%d,\"flag_m\":%d,\"flag_a\":%d,"
|
| 223 |
+
"\"fd\":%lu,\"bk\":%lu,"
|
| 224 |
+
"\"fd_idx\":%d,\"bk_idx\":%d,"
|
| 225 |
+
"\"alloc_order\":%d,\"free_order\":%d,"
|
| 226 |
+
"\"is_target\":%d,\"is_double_freed\":%d,"
|
| 227 |
+
"\"data_hex\":\"%s\"}",
|
| 228 |
+
i, g_chunks[i].user_ptr, g_chunks[i].state,
|
| 229 |
+
g_chunks[i].req_size, ci.size,
|
| 230 |
+
ci.prev_size, ci.size_raw,
|
| 231 |
+
ci.flag_p, ci.flag_m, ci.flag_a,
|
| 232 |
+
(unsigned long)ci.fd, (unsigned long)ci.bk,
|
| 233 |
+
fd_idx, bk_idx,
|
| 234 |
+
g_chunks[i].alloc_order, g_chunks[i].free_order,
|
| 235 |
+
is_target, dbl_free,
|
| 236 |
+
data_hex);
|
| 237 |
+
|
| 238 |
+
if (pos >= DUMP_BUF_SIZE - 1024) break; /* safety */
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos, "]}\n");
|
| 242 |
+
|
| 243 |
+
write(g_out_fd, buf, pos);
|
| 244 |
+
g_step++;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* --- Hooked functions --- */
|
| 248 |
+
|
| 249 |
+
void *malloc(size_t size) {
|
| 250 |
+
init_hooks();
|
| 251 |
+
if (g_in_hook) return real_malloc(size);
|
| 252 |
+
|
| 253 |
+
g_in_hook = 1;
|
| 254 |
+
void *ptr = real_malloc(size);
|
| 255 |
+
if (ptr) add_chunk(ptr, size);
|
| 256 |
+
dump_state("malloc", ptr, size);
|
| 257 |
+
g_in_hook = 0;
|
| 258 |
+
return ptr;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
void free(void *ptr) {
|
| 262 |
+
init_hooks();
|
| 263 |
+
if (g_in_hook || !ptr) {
|
| 264 |
+
real_free(ptr);
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
g_in_hook = 1;
|
| 269 |
+
int idx = find_allocated_chunk(ptr);
|
| 270 |
+
if (idx >= 0) {
|
| 271 |
+
mark_freed(idx);
|
| 272 |
+
} else {
|
| 273 |
+
/* Might be a double-free or untracked chunk - still track for analysis */
|
| 274 |
+
int any = find_chunk(ptr);
|
| 275 |
+
if (any >= 0) {
|
| 276 |
+
/* Double free - add a new entry to track it */
|
| 277 |
+
if (g_chunk_count < MAX_CHUNKS) {
|
| 278 |
+
int slot = g_chunk_count++;
|
| 279 |
+
g_chunks[slot] = g_chunks[any];
|
| 280 |
+
g_chunks[slot].state = 2;
|
| 281 |
+
g_chunks[slot].free_order = ++g_free_seq;
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
real_free(ptr);
|
| 286 |
+
dump_state("free", ptr, 0);
|
| 287 |
+
g_in_hook = 0;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
void *calloc(size_t nmemb, size_t size) {
|
| 291 |
+
init_hooks();
|
| 292 |
+
|
| 293 |
+
/* dlsym may call calloc before we have real_calloc */
|
| 294 |
+
if (!real_calloc) {
|
| 295 |
+
if (early_buf_used + nmemb * size <= sizeof(early_buf)) {
|
| 296 |
+
void *p = early_buf + early_buf_used;
|
| 297 |
+
early_buf_used += nmemb * size;
|
| 298 |
+
memset(p, 0, nmemb * size);
|
| 299 |
+
return p;
|
| 300 |
+
}
|
| 301 |
+
return NULL;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
if (g_in_hook) return real_calloc(nmemb, size);
|
| 305 |
+
|
| 306 |
+
g_in_hook = 1;
|
| 307 |
+
void *ptr = real_calloc(nmemb, size);
|
| 308 |
+
if (ptr) add_chunk(ptr, nmemb * size);
|
| 309 |
+
dump_state("calloc", ptr, nmemb * size);
|
| 310 |
+
g_in_hook = 0;
|
| 311 |
+
return ptr;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
void *realloc(void *old_ptr, size_t size) {
|
| 315 |
+
init_hooks();
|
| 316 |
+
if (g_in_hook) return real_realloc(old_ptr, size);
|
| 317 |
+
|
| 318 |
+
g_in_hook = 1;
|
| 319 |
+
if (old_ptr) {
|
| 320 |
+
int idx = find_allocated_chunk(old_ptr);
|
| 321 |
+
if (idx >= 0) g_chunks[idx].state = 0; /* mark old as gone */
|
| 322 |
+
}
|
| 323 |
+
void *ptr = real_realloc(old_ptr, size);
|
| 324 |
+
if (ptr) add_chunk(ptr, size);
|
| 325 |
+
dump_state("realloc", ptr, size);
|
| 326 |
+
g_in_hook = 0;
|
| 327 |
+
return ptr;
|
| 328 |
+
}
|
heaptrm/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
heaptrm - Tiny Recursive Model heap exploit detector.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from heaptrm import HeapMonitor
|
| 6 |
+
|
| 7 |
+
# Standalone
|
| 8 |
+
monitor = HeapMonitor()
|
| 9 |
+
results = monitor.scan("./vulnerable_binary", args=["input"])
|
| 10 |
+
|
| 11 |
+
# With pwntools
|
| 12 |
+
from pwn import process
|
| 13 |
+
p = process("./vulnerable_binary")
|
| 14 |
+
monitor = HeapMonitor.attach(p)
|
| 15 |
+
monitor.check() # returns exploit probability
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
__version__ = "0.1.0"
|
heaptrm/classifier/__init__.py
ADDED
|
File without changes
|
heaptrm/classifier/grid.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
grid.py - Encode heap dump states to 32x16 grids.
|
| 3 |
+
|
| 4 |
+
Enhanced for v2 harness: includes corruption detection fields.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
GRID_ROWS = 32
|
| 12 |
+
GRID_COLS = 16
|
| 13 |
+
VOCAB = 64
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def clamp(v, lo=0, hi=63):
|
| 17 |
+
return max(lo, min(hi, int(v)))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def state_to_grid(state: dict) -> np.ndarray:
|
| 21 |
+
"""Convert a heap dump state (v2 format) to a 32x16 grid."""
|
| 22 |
+
grid = np.zeros((GRID_ROWS, GRID_COLS), dtype=np.int64)
|
| 23 |
+
chunks = state.get("chunks", [])
|
| 24 |
+
corruption_count = state.get("corruption_count", 0)
|
| 25 |
+
|
| 26 |
+
for i, c in enumerate(chunks[:GRID_ROWS - 2]):
|
| 27 |
+
# Col 0: state
|
| 28 |
+
grid[i, 0] = clamp(c.get("state", 0), 0, 2)
|
| 29 |
+
# Col 1: size class
|
| 30 |
+
grid[i, 1] = clamp(c.get("chunk_size", 0) >> 4)
|
| 31 |
+
# Col 2: prev_inuse
|
| 32 |
+
grid[i, 2] = c.get("flag_p", 0)
|
| 33 |
+
# Col 3: is_mmapped
|
| 34 |
+
grid[i, 3] = c.get("flag_m", 0)
|
| 35 |
+
# Col 4: fd target
|
| 36 |
+
fd_idx = c.get("fd_idx", -1)
|
| 37 |
+
grid[i, 4] = 0 if fd_idx == -1 else (33 if fd_idx == -2 else clamp(fd_idx + 1, 1, 32))
|
| 38 |
+
# Col 5: bk target
|
| 39 |
+
bk_idx = c.get("bk_idx", -1)
|
| 40 |
+
grid[i, 5] = 0 if bk_idx == -1 else (33 if bk_idx == -2 else clamp(bk_idx + 1, 1, 32))
|
| 41 |
+
# Col 6: alloc order
|
| 42 |
+
grid[i, 6] = clamp(c.get("alloc_order", 0))
|
| 43 |
+
# Col 7: free order
|
| 44 |
+
grid[i, 7] = clamp(c.get("free_order", 0))
|
| 45 |
+
# Col 8: is target of last op
|
| 46 |
+
grid[i, 8] = c.get("is_target", 0)
|
| 47 |
+
# Col 9: is double freed
|
| 48 |
+
grid[i, 9] = c.get("is_double_freed", 0)
|
| 49 |
+
# Col 10: is corrupted (NEW in v2)
|
| 50 |
+
grid[i, 10] = c.get("is_corrupted", 0)
|
| 51 |
+
# Col 11: req size class
|
| 52 |
+
grid[i, 11] = clamp(c.get("req_size", 0) >> 3)
|
| 53 |
+
# Col 12: data bytes (first 4, quantized)
|
| 54 |
+
data_hex = c.get("data_hex", "")
|
| 55 |
+
for j in range(min(4, len(data_hex) // 2)):
|
| 56 |
+
byte_val = int(data_hex[j*2:j*2+2], 16)
|
| 57 |
+
grid[i, 12 + j] = clamp(byte_val >> 2) # 6-bit quantize
|
| 58 |
+
|
| 59 |
+
# Summary row (row 30): global corruption info
|
| 60 |
+
sr = GRID_ROWS - 2
|
| 61 |
+
grid[sr, 0] = 50 # marker
|
| 62 |
+
grid[sr, 1] = clamp(sum(1 for c in chunks if c.get("state") == 1)) # n_alloc
|
| 63 |
+
grid[sr, 2] = clamp(sum(1 for c in chunks if c.get("state") == 2)) # n_freed
|
| 64 |
+
grid[sr, 3] = clamp(len(chunks))
|
| 65 |
+
grid[sr, 4] = clamp(corruption_count) # total corruptions detected
|
| 66 |
+
# Count specific corruption types
|
| 67 |
+
corruptions = state.get("corruptions", [])
|
| 68 |
+
grid[sr, 5] = clamp(sum(1 for c in corruptions if c.get("type") == "metadata_corrupt"))
|
| 69 |
+
grid[sr, 6] = clamp(sum(1 for c in corruptions if c.get("type") == "uaf_write"))
|
| 70 |
+
grid[sr, 7] = clamp(sum(1 for c in corruptions if c.get("type") == "double_free"))
|
| 71 |
+
grid[sr, 8] = clamp(sum(1 for c in corruptions if c.get("type") == "overflow"))
|
| 72 |
+
# Has ANY corruption
|
| 73 |
+
grid[sr, 9] = 1 if corruption_count > 0 else 0
|
| 74 |
+
|
| 75 |
+
return grid
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_dump(path):
|
| 79 |
+
"""Load a JSONL dump file."""
|
| 80 |
+
states = []
|
| 81 |
+
with open(path) as f:
|
| 82 |
+
for line in f:
|
| 83 |
+
if line.strip():
|
| 84 |
+
states.append(json.loads(line.strip()))
|
| 85 |
+
return states
|
heaptrm/classifier/model.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
model.py - TRM classifier for heap exploit detection.
|
| 3 |
+
|
| 4 |
+
Standalone module with no external dependencies beyond torch + numpy.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
import numpy as np
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RMSNorm(nn.Module):
|
| 15 |
+
def __init__(self, dim, eps=1e-6):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.eps = eps
|
| 18 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 19 |
+
|
| 20 |
+
def forward(self, x):
|
| 21 |
+
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) * self.weight
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SwiGLU(nn.Module):
|
| 25 |
+
def __init__(self, dim):
|
| 26 |
+
super().__init__()
|
| 27 |
+
self.w1 = nn.Linear(dim, dim * 2, bias=False)
|
| 28 |
+
self.w2 = nn.Linear(dim, dim, bias=False)
|
| 29 |
+
|
| 30 |
+
def forward(self, x):
|
| 31 |
+
gate, val = self.w1(x).chunk(2, dim=-1)
|
| 32 |
+
return self.w2(F.silu(gate) * val)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RecursionBlock(nn.Module):
|
| 36 |
+
def __init__(self, dim):
|
| 37 |
+
super().__init__()
|
| 38 |
+
self.norm1 = RMSNorm(dim)
|
| 39 |
+
self.swiglu = SwiGLU(dim)
|
| 40 |
+
self.norm2 = RMSNorm(dim)
|
| 41 |
+
|
| 42 |
+
def forward(self, x):
|
| 43 |
+
return self.norm2(self.swiglu(self.norm1(x)))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class HeapTRM(nn.Module):
|
| 47 |
+
"""Tiny Recursive Model for heap exploit classification."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, vocab_size=64, hidden_dim=128, seq_len=512,
|
| 50 |
+
n_outer=2, n_inner=3, n_classes=2):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.n_outer = n_outer
|
| 53 |
+
self.n_inner = n_inner
|
| 54 |
+
self.embed = nn.Embedding(vocab_size, hidden_dim)
|
| 55 |
+
self.y_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 56 |
+
self.z_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 57 |
+
self.block_z = RecursionBlock(hidden_dim)
|
| 58 |
+
self.block_y = RecursionBlock(hidden_dim)
|
| 59 |
+
self.pos_embed = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 60 |
+
self.out_norm = RMSNorm(hidden_dim)
|
| 61 |
+
self.out_head = nn.Linear(hidden_dim, n_classes)
|
| 62 |
+
|
| 63 |
+
def forward(self, x):
|
| 64 |
+
B = x.shape[0]
|
| 65 |
+
h = self.embed(x.reshape(B, -1)) + self.pos_embed
|
| 66 |
+
y = self.y_init.expand(B, -1, -1)
|
| 67 |
+
z = self.z_init.expand(B, -1, -1)
|
| 68 |
+
for _ in range(self.n_outer):
|
| 69 |
+
for _ in range(self.n_inner):
|
| 70 |
+
z = z + self.block_z(h + y + z)
|
| 71 |
+
y = y + self.block_y(y + z)
|
| 72 |
+
pooled = self.out_norm(y).mean(dim=1)
|
| 73 |
+
return self.out_head(pooled)
|
heaptrm/cli.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
heaptrm CLI - Scan binaries for heap exploitation patterns.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
heaptrm scan ./binary [args...]
|
| 7 |
+
heaptrm scan --stdin payload.bin ./binary
|
| 8 |
+
heaptrm analyze dump.jsonl
|
| 9 |
+
heaptrm watch ./binary # live monitoring
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import sys
|
| 14 |
+
import os
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Add parent to path for imports
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 20 |
+
|
| 21 |
+
from heaptrm.monitor import HeapMonitor
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def cmd_scan(args):
|
| 25 |
+
"""Scan a binary for heap exploitation."""
|
| 26 |
+
monitor = HeapMonitor(model_path=args.model)
|
| 27 |
+
|
| 28 |
+
stdin_data = None
|
| 29 |
+
if args.stdin:
|
| 30 |
+
with open(args.stdin, "rb") as f:
|
| 31 |
+
stdin_data = f.read()
|
| 32 |
+
|
| 33 |
+
result = monitor.scan(
|
| 34 |
+
args.binary, args=args.args,
|
| 35 |
+
stdin_data=stdin_data, timeout=args.timeout
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Output
|
| 39 |
+
if args.json:
|
| 40 |
+
print(json.dumps({
|
| 41 |
+
"verdict": result.verdict,
|
| 42 |
+
"confidence": result.confidence,
|
| 43 |
+
"states": result.n_states,
|
| 44 |
+
"flagged": result.n_flagged,
|
| 45 |
+
"corruptions": [
|
| 46 |
+
{"type": c.type, "step": c.step, "detail": c.detail}
|
| 47 |
+
for c in result.corruptions
|
| 48 |
+
],
|
| 49 |
+
}, indent=2))
|
| 50 |
+
else:
|
| 51 |
+
icon = {"EXPLOIT": "!!", "SUSPICIOUS": "??", "CLEAN": "OK"}[result.verdict]
|
| 52 |
+
print(f"[{icon}] {result.verdict} (confidence: {result.confidence:.1%})")
|
| 53 |
+
print(f" States observed: {result.n_states}")
|
| 54 |
+
print(f" States flagged: {result.n_flagged}")
|
| 55 |
+
if result.corruptions:
|
| 56 |
+
print(f" Corruptions detected:")
|
| 57 |
+
for c in result.corruptions:
|
| 58 |
+
print(f" step {c.step}: {c.type} — {c.detail}")
|
| 59 |
+
|
| 60 |
+
return 0 if result.verdict == "CLEAN" else 1
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def cmd_analyze(args):
|
| 64 |
+
"""Analyze an existing heap dump."""
|
| 65 |
+
monitor = HeapMonitor(model_path=args.model)
|
| 66 |
+
result = monitor.analyze_dump(args.dump)
|
| 67 |
+
|
| 68 |
+
icon = {"EXPLOIT": "!!", "SUSPICIOUS": "??", "CLEAN": "OK"}[result.verdict]
|
| 69 |
+
print(f"[{icon}] {result.verdict} (confidence: {result.confidence:.1%})")
|
| 70 |
+
print(f" States: {result.n_states}, Flagged: {result.n_flagged}")
|
| 71 |
+
if result.corruptions:
|
| 72 |
+
for c in result.corruptions:
|
| 73 |
+
print(f" step {c.step}: {c.type} — {c.detail}")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def cmd_watch(args):
|
| 77 |
+
"""Live-watch a binary."""
|
| 78 |
+
import subprocess
|
| 79 |
+
import tempfile
|
| 80 |
+
import time
|
| 81 |
+
|
| 82 |
+
monitor = HeapMonitor(model_path=args.model)
|
| 83 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 84 |
+
|
| 85 |
+
env = os.environ.copy()
|
| 86 |
+
env["LD_PRELOAD"] = monitor._harness_path
|
| 87 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 88 |
+
|
| 89 |
+
cmd = [args.binary] + (args.args or [])
|
| 90 |
+
proc = subprocess.Popen(cmd, env=env)
|
| 91 |
+
|
| 92 |
+
print(f"Watching {args.binary} (PID {proc.pid})...")
|
| 93 |
+
last_pos = 0
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
while proc.poll() is None:
|
| 97 |
+
time.sleep(0.1)
|
| 98 |
+
if os.path.exists(dump_path):
|
| 99 |
+
with open(dump_path) as f:
|
| 100 |
+
f.seek(last_pos)
|
| 101 |
+
for line in f:
|
| 102 |
+
if line.strip():
|
| 103 |
+
state = json.loads(line.strip())
|
| 104 |
+
corruptions = state.get("corruptions", [])
|
| 105 |
+
if corruptions:
|
| 106 |
+
for c in corruptions:
|
| 107 |
+
print(f" [!!] step {state['step']}: "
|
| 108 |
+
f"{c['type']} — {c['detail']}")
|
| 109 |
+
last_pos = f.tell()
|
| 110 |
+
except KeyboardInterrupt:
|
| 111 |
+
proc.terminate()
|
| 112 |
+
|
| 113 |
+
# Final analysis
|
| 114 |
+
if os.path.exists(dump_path):
|
| 115 |
+
result = monitor.analyze_dump(dump_path)
|
| 116 |
+
print(f"\nFinal: [{result.verdict}] {result.n_states} states, "
|
| 117 |
+
f"{len(result.corruptions)} corruptions")
|
| 118 |
+
os.unlink(dump_path)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main():
|
| 122 |
+
parser = argparse.ArgumentParser(
|
| 123 |
+
prog="heaptrm",
|
| 124 |
+
description="Heap exploit detection using Tiny Recursive Models"
|
| 125 |
+
)
|
| 126 |
+
parser.add_argument("--model", help="Path to trained .pt model")
|
| 127 |
+
|
| 128 |
+
sub = parser.add_subparsers(dest="command")
|
| 129 |
+
|
| 130 |
+
# scan
|
| 131 |
+
p_scan = sub.add_parser("scan", help="Scan a binary")
|
| 132 |
+
p_scan.add_argument("binary")
|
| 133 |
+
p_scan.add_argument("args", nargs="*")
|
| 134 |
+
p_scan.add_argument("--stdin", help="File to pipe to stdin")
|
| 135 |
+
p_scan.add_argument("--timeout", type=int, default=30)
|
| 136 |
+
p_scan.add_argument("--json", action="store_true")
|
| 137 |
+
|
| 138 |
+
# analyze
|
| 139 |
+
p_analyze = sub.add_parser("analyze", help="Analyze a heap dump")
|
| 140 |
+
p_analyze.add_argument("dump")
|
| 141 |
+
|
| 142 |
+
# watch
|
| 143 |
+
p_watch = sub.add_parser("watch", help="Live-watch a binary")
|
| 144 |
+
p_watch.add_argument("binary")
|
| 145 |
+
p_watch.add_argument("args", nargs="*")
|
| 146 |
+
|
| 147 |
+
args = parser.parse_args()
|
| 148 |
+
|
| 149 |
+
if args.command == "scan":
|
| 150 |
+
sys.exit(cmd_scan(args))
|
| 151 |
+
elif args.command == "analyze":
|
| 152 |
+
cmd_analyze(args)
|
| 153 |
+
elif args.command == "watch":
|
| 154 |
+
cmd_watch(args)
|
| 155 |
+
else:
|
| 156 |
+
parser.print_help()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
main()
|
heaptrm/harness/heapgrid_v2.c
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* heapgrid_v2.c - Enhanced heap instrumentation harness.
|
| 3 |
+
*
|
| 4 |
+
* Improvements over v1:
|
| 5 |
+
* 1. Chunk metadata validation: after every alloc/free, verify all
|
| 6 |
+
* tracked chunk headers haven't been corrupted since last check.
|
| 7 |
+
* Detects heap overflows that corrupt adjacent chunk metadata.
|
| 8 |
+
* 2. Canary words: place canaries at chunk boundaries to detect
|
| 9 |
+
* linear overflows.
|
| 10 |
+
* 3. Data-change tracking: hash user data on alloc, detect changes
|
| 11 |
+
* in freed chunks (UAF write detection).
|
| 12 |
+
* 4. Corruption events emitted in JSONL alongside heap state.
|
| 13 |
+
*
|
| 14 |
+
* Build: gcc -shared -fPIC -O2 -o heapgrid_v2.so heapgrid_v2.c -ldl -pthread
|
| 15 |
+
* Usage: LD_PRELOAD=./heapgrid_v2.so HEAPGRID_OUT=dump.jsonl ./target
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
#define _GNU_SOURCE
|
| 19 |
+
#include <dlfcn.h>
|
| 20 |
+
#include <stdio.h>
|
| 21 |
+
#include <stdlib.h>
|
| 22 |
+
#include <string.h>
|
| 23 |
+
#include <unistd.h>
|
| 24 |
+
#include <stdint.h>
|
| 25 |
+
#include <fcntl.h>
|
| 26 |
+
|
| 27 |
+
#define MAX_CHUNKS 256
|
| 28 |
+
#define DATA_PEEK_BYTES 32
|
| 29 |
+
#define DUMP_BUF_SIZE (1024 * 128)
|
| 30 |
+
#define CANARY_VALUE 0xDEADBEEFCAFEBABEULL
|
| 31 |
+
|
| 32 |
+
typedef struct {
|
| 33 |
+
void *user_ptr;
|
| 34 |
+
size_t req_size;
|
| 35 |
+
size_t chunk_size; /* actual chunk size from header */
|
| 36 |
+
size_t saved_size_raw; /* saved size field for corruption detection */
|
| 37 |
+
size_t saved_prev_size; /* saved prev_size for corruption detection */
|
| 38 |
+
uint64_t data_hash; /* hash of user data for UAF detection */
|
| 39 |
+
int state; /* 0=unused, 1=allocated, 2=freed */
|
| 40 |
+
int alloc_order;
|
| 41 |
+
int free_order;
|
| 42 |
+
int hash_stable; /* 0=just freed (glibc still writing), 1=post-free hash recorded */
|
| 43 |
+
} tracked_chunk_t;
|
| 44 |
+
|
| 45 |
+
typedef struct {
|
| 46 |
+
int step;
|
| 47 |
+
char type[32]; /* "overflow", "uaf_write", "double_free", "metadata_corrupt" */
|
| 48 |
+
int chunk_idx;
|
| 49 |
+
char detail[256];
|
| 50 |
+
} corruption_event_t;
|
| 51 |
+
|
| 52 |
+
static tracked_chunk_t g_chunks[MAX_CHUNKS];
|
| 53 |
+
static int g_chunk_count = 0;
|
| 54 |
+
static int g_step = 0;
|
| 55 |
+
static int g_alloc_seq = 0;
|
| 56 |
+
static int g_free_seq = 0;
|
| 57 |
+
static int g_out_fd = -1;
|
| 58 |
+
static int g_initialized = 0;
|
| 59 |
+
static __thread int g_in_hook = 0;
|
| 60 |
+
|
| 61 |
+
/* Corruption tracking */
|
| 62 |
+
static corruption_event_t g_corruptions[64];
|
| 63 |
+
static int g_corruption_count = 0;
|
| 64 |
+
|
| 65 |
+
static void *(*real_malloc)(size_t) = NULL;
|
| 66 |
+
static void (*real_free)(void *) = NULL;
|
| 67 |
+
static void *(*real_calloc)(size_t, size_t) = NULL;
|
| 68 |
+
static void *(*real_realloc)(void *, size_t) = NULL;
|
| 69 |
+
|
| 70 |
+
static char early_buf[4096];
|
| 71 |
+
static int early_buf_used = 0;
|
| 72 |
+
|
| 73 |
+
/* --- Simple hash for data-change detection --- */
|
| 74 |
+
static uint64_t hash_bytes(const void *data, size_t len) {
|
| 75 |
+
const uint8_t *p = (const uint8_t *)data;
|
| 76 |
+
uint64_t h = 0xcbf29ce484222325ULL;
|
| 77 |
+
size_t n = len < DATA_PEEK_BYTES ? len : DATA_PEEK_BYTES;
|
| 78 |
+
for (size_t i = 0; i < n; i++) {
|
| 79 |
+
h ^= p[i];
|
| 80 |
+
h *= 0x100000001b3ULL;
|
| 81 |
+
}
|
| 82 |
+
return h;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
static void init_hooks(void) {
|
| 86 |
+
if (g_initialized) return;
|
| 87 |
+
g_initialized = 1;
|
| 88 |
+
real_malloc = dlsym(RTLD_NEXT, "malloc");
|
| 89 |
+
real_free = dlsym(RTLD_NEXT, "free");
|
| 90 |
+
real_calloc = dlsym(RTLD_NEXT, "calloc");
|
| 91 |
+
real_realloc = dlsym(RTLD_NEXT, "realloc");
|
| 92 |
+
|
| 93 |
+
const char *outfile = getenv("HEAPGRID_OUT");
|
| 94 |
+
if (!outfile) outfile = "heap_dump.jsonl";
|
| 95 |
+
g_out_fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
| 96 |
+
if (g_out_fd < 0) g_out_fd = STDERR_FILENO;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* --- Chunk tracking --- */
|
| 100 |
+
|
| 101 |
+
static int find_chunk(void *ptr) {
|
| 102 |
+
for (int i = 0; i < g_chunk_count; i++)
|
| 103 |
+
if (g_chunks[i].user_ptr == ptr && g_chunks[i].state != 0)
|
| 104 |
+
return i;
|
| 105 |
+
return -1;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
static int find_allocated(void *ptr) {
|
| 109 |
+
for (int i = 0; i < g_chunk_count; i++)
|
| 110 |
+
if (g_chunks[i].user_ptr == ptr && g_chunks[i].state == 1)
|
| 111 |
+
return i;
|
| 112 |
+
return -1;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
static int add_chunk(void *ptr, size_t req_size) {
|
| 116 |
+
int slot = -1;
|
| 117 |
+
for (int i = 0; i < g_chunk_count; i++)
|
| 118 |
+
if (g_chunks[i].state == 0) { slot = i; break; }
|
| 119 |
+
if (slot < 0) {
|
| 120 |
+
if (g_chunk_count >= MAX_CHUNKS) return -1;
|
| 121 |
+
slot = g_chunk_count++;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
size_t *hdr = (size_t *)((char *)ptr - 2 * sizeof(size_t));
|
| 125 |
+
size_t size_raw = hdr[1];
|
| 126 |
+
size_t chunk_size = size_raw & ~0x7UL;
|
| 127 |
+
|
| 128 |
+
g_chunks[slot].user_ptr = ptr;
|
| 129 |
+
g_chunks[slot].req_size = req_size;
|
| 130 |
+
g_chunks[slot].chunk_size = chunk_size;
|
| 131 |
+
g_chunks[slot].saved_size_raw = size_raw;
|
| 132 |
+
g_chunks[slot].saved_prev_size = hdr[0];
|
| 133 |
+
g_chunks[slot].data_hash = hash_bytes(ptr, req_size);
|
| 134 |
+
g_chunks[slot].state = 1;
|
| 135 |
+
g_chunks[slot].alloc_order = ++g_alloc_seq;
|
| 136 |
+
g_chunks[slot].free_order = 0;
|
| 137 |
+
return slot;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* --- CORRUPTION DETECTION --- */
|
| 141 |
+
|
| 142 |
+
static void add_corruption(const char *type, int chunk_idx, const char *detail) {
|
| 143 |
+
if (g_corruption_count >= 64) return;
|
| 144 |
+
corruption_event_t *e = &g_corruptions[g_corruption_count++];
|
| 145 |
+
e->step = g_step;
|
| 146 |
+
e->chunk_idx = chunk_idx;
|
| 147 |
+
strncpy(e->type, type, sizeof(e->type) - 1);
|
| 148 |
+
strncpy(e->detail, detail, sizeof(e->detail) - 1);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
static void validate_all_chunks(void) {
|
| 152 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 153 |
+
if (g_chunks[i].state == 0) continue;
|
| 154 |
+
|
| 155 |
+
void *ptr = g_chunks[i].user_ptr;
|
| 156 |
+
size_t *hdr = (size_t *)((char *)ptr - 2 * sizeof(size_t));
|
| 157 |
+
size_t cur_size_raw = hdr[1];
|
| 158 |
+
size_t cur_prev_size = hdr[0];
|
| 159 |
+
|
| 160 |
+
/* Check 1: Size field corrupted (overflow from previous chunk) */
|
| 161 |
+
if (cur_size_raw != g_chunks[i].saved_size_raw) {
|
| 162 |
+
char detail[256];
|
| 163 |
+
snprintf(detail, sizeof(detail),
|
| 164 |
+
"size_raw changed: 0x%lx -> 0x%lx (overflow detected)",
|
| 165 |
+
g_chunks[i].saved_size_raw, cur_size_raw);
|
| 166 |
+
add_corruption("metadata_corrupt", i, detail);
|
| 167 |
+
g_chunks[i].saved_size_raw = cur_size_raw; /* update to avoid re-reporting */
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* Check 2: prev_size corrupted */
|
| 171 |
+
if (cur_prev_size != g_chunks[i].saved_prev_size &&
|
| 172 |
+
g_chunks[i].saved_prev_size != 0) {
|
| 173 |
+
/* prev_size changes legitimately on free, so only flag if chunk is allocated */
|
| 174 |
+
if (g_chunks[i].state == 1) {
|
| 175 |
+
char detail[256];
|
| 176 |
+
snprintf(detail, sizeof(detail),
|
| 177 |
+
"prev_size changed: 0x%lx -> 0x%lx",
|
| 178 |
+
g_chunks[i].saved_prev_size, cur_prev_size);
|
| 179 |
+
add_corruption("metadata_corrupt", i, detail);
|
| 180 |
+
}
|
| 181 |
+
g_chunks[i].saved_prev_size = cur_prev_size;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* Check 3: UAF detection is NOT done here.
|
| 185 |
+
* Reliable UAF detection requires compile-time instrumentation (ASAN)
|
| 186 |
+
* or hardware watchpoints. LD_PRELOAD cannot distinguish glibc's
|
| 187 |
+
* legitimate writes to freed chunks from attacker UAF writes.
|
| 188 |
+
* We rely on metadata_corrupt and double_free checks instead. */
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* Check 4: Double-free detection — only flag in free() handler,
|
| 192 |
+
* not here, to avoid re-reporting on every validation pass. */
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* --- Dump state --- */
|
| 196 |
+
static void dump_state(const char *op, void *target_ptr, size_t size) {
|
| 197 |
+
/* Validate all chunks BEFORE dumping */
|
| 198 |
+
validate_all_chunks();
|
| 199 |
+
|
| 200 |
+
static char buf[DUMP_BUF_SIZE];
|
| 201 |
+
int pos = 0;
|
| 202 |
+
|
| 203 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos,
|
| 204 |
+
"{\"step\":%d,\"operation\":\"%s\",\"target_addr\":\"%p\","
|
| 205 |
+
"\"target_size\":%zu,\"corruption_count\":%d,",
|
| 206 |
+
g_step, op, target_ptr, size, g_corruption_count);
|
| 207 |
+
|
| 208 |
+
/* Emit corruption events */
|
| 209 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos, "\"corruptions\":[");
|
| 210 |
+
for (int c = 0; c < g_corruption_count; c++) {
|
| 211 |
+
if (c > 0) buf[pos++] = ',';
|
| 212 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos,
|
| 213 |
+
"{\"type\":\"%s\",\"chunk_idx\":%d,\"detail\":\"%s\"}",
|
| 214 |
+
g_corruptions[c].type, g_corruptions[c].chunk_idx,
|
| 215 |
+
g_corruptions[c].detail);
|
| 216 |
+
if (pos >= DUMP_BUF_SIZE - 1024) break;
|
| 217 |
+
}
|
| 218 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos, "],");
|
| 219 |
+
|
| 220 |
+
/* Emit chunks */
|
| 221 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos, "\"chunks\":[");
|
| 222 |
+
int first = 1;
|
| 223 |
+
for (int i = 0; i < g_chunk_count; i++) {
|
| 224 |
+
if (g_chunks[i].state == 0) continue;
|
| 225 |
+
void *ptr = g_chunks[i].user_ptr;
|
| 226 |
+
size_t *hdr = (size_t *)((char *)ptr - 2 * sizeof(size_t));
|
| 227 |
+
size_t size_raw = hdr[1];
|
| 228 |
+
size_t prev_sz = hdr[0];
|
| 229 |
+
size_t real_sz = size_raw & ~0x7UL;
|
| 230 |
+
|
| 231 |
+
uint64_t fd = 0, bk = 0;
|
| 232 |
+
if (g_chunks[i].req_size >= 8)
|
| 233 |
+
memcpy(&fd, ptr, 8);
|
| 234 |
+
if (g_chunks[i].req_size >= 16)
|
| 235 |
+
memcpy(&bk, (char *)ptr + 8, 8);
|
| 236 |
+
|
| 237 |
+
/* Resolve fd to chunk index */
|
| 238 |
+
int fd_idx = -1;
|
| 239 |
+
if (fd == 0) fd_idx = -1;
|
| 240 |
+
else {
|
| 241 |
+
for (int j = 0; j < g_chunk_count; j++)
|
| 242 |
+
if (g_chunks[j].state != 0 && (uint64_t)g_chunks[j].user_ptr == fd)
|
| 243 |
+
{ fd_idx = j; break; }
|
| 244 |
+
if (fd_idx == -1) fd_idx = -2; /* external */
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
int bk_idx = -1;
|
| 248 |
+
if (bk == 0) bk_idx = -1;
|
| 249 |
+
else {
|
| 250 |
+
for (int j = 0; j < g_chunk_count; j++)
|
| 251 |
+
if (g_chunks[j].state != 0 && (uint64_t)g_chunks[j].user_ptr == bk)
|
| 252 |
+
{ bk_idx = j; break; }
|
| 253 |
+
if (bk_idx == -1) bk_idx = -2;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
int is_target = (ptr == target_ptr) ? 1 : 0;
|
| 257 |
+
int is_corrupted = 0;
|
| 258 |
+
for (int c = 0; c < g_corruption_count; c++)
|
| 259 |
+
if (g_corruptions[c].chunk_idx == i)
|
| 260 |
+
{ is_corrupted = 1; break; }
|
| 261 |
+
|
| 262 |
+
/* Double-free check */
|
| 263 |
+
int is_double_freed = 0;
|
| 264 |
+
for (int j = 0; j < g_chunk_count; j++)
|
| 265 |
+
if (j != i && g_chunks[j].state == 2 &&
|
| 266 |
+
g_chunks[j].user_ptr == ptr && g_chunks[i].state == 2)
|
| 267 |
+
{ is_double_freed = 1; break; }
|
| 268 |
+
|
| 269 |
+
/* Data hex */
|
| 270 |
+
char data_hex[DATA_PEEK_BYTES * 2 + 1];
|
| 271 |
+
int dlen = g_chunks[i].req_size < DATA_PEEK_BYTES ?
|
| 272 |
+
g_chunks[i].req_size : DATA_PEEK_BYTES;
|
| 273 |
+
for (int j = 0; j < dlen; j++)
|
| 274 |
+
snprintf(data_hex + j*2, 3, "%02x", ((uint8_t*)ptr)[j]);
|
| 275 |
+
data_hex[dlen * 2] = '\0';
|
| 276 |
+
|
| 277 |
+
if (!first) buf[pos++] = ',';
|
| 278 |
+
first = 0;
|
| 279 |
+
|
| 280 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos,
|
| 281 |
+
"{\"idx\":%d,\"addr\":\"%p\",\"state\":%d,"
|
| 282 |
+
"\"req_size\":%zu,\"chunk_size\":%zu,"
|
| 283 |
+
"\"prev_size\":%zu,\"size_raw\":%zu,"
|
| 284 |
+
"\"flag_p\":%d,\"flag_m\":%d,\"flag_a\":%d,"
|
| 285 |
+
"\"fd\":%lu,\"bk\":%lu,"
|
| 286 |
+
"\"fd_idx\":%d,\"bk_idx\":%d,"
|
| 287 |
+
"\"alloc_order\":%d,\"free_order\":%d,"
|
| 288 |
+
"\"is_target\":%d,\"is_double_freed\":%d,"
|
| 289 |
+
"\"is_corrupted\":%d,"
|
| 290 |
+
"\"data_hex\":\"%s\"}",
|
| 291 |
+
i, ptr, g_chunks[i].state,
|
| 292 |
+
g_chunks[i].req_size, real_sz,
|
| 293 |
+
prev_sz, size_raw,
|
| 294 |
+
(int)(size_raw & 1), (int)((size_raw >> 1) & 1), (int)((size_raw >> 2) & 1),
|
| 295 |
+
(unsigned long)fd, (unsigned long)bk,
|
| 296 |
+
fd_idx, bk_idx,
|
| 297 |
+
g_chunks[i].alloc_order, g_chunks[i].free_order,
|
| 298 |
+
is_target, is_double_freed,
|
| 299 |
+
is_corrupted,
|
| 300 |
+
data_hex);
|
| 301 |
+
|
| 302 |
+
if (pos >= DUMP_BUF_SIZE - 2048) break;
|
| 303 |
+
}
|
| 304 |
+
pos += snprintf(buf + pos, DUMP_BUF_SIZE - pos, "]}\n");
|
| 305 |
+
|
| 306 |
+
write(g_out_fd, buf, pos);
|
| 307 |
+
g_step++;
|
| 308 |
+
/* Reset corruption events for next dump (already written) */
|
| 309 |
+
g_corruption_count = 0;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/* --- Hooked functions --- */
|
| 313 |
+
|
| 314 |
+
void *malloc(size_t size) {
|
| 315 |
+
init_hooks();
|
| 316 |
+
if (g_in_hook) return real_malloc(size);
|
| 317 |
+
g_in_hook = 1;
|
| 318 |
+
void *ptr = real_malloc(size);
|
| 319 |
+
if (ptr) add_chunk(ptr, size);
|
| 320 |
+
dump_state("malloc", ptr, size);
|
| 321 |
+
g_in_hook = 0;
|
| 322 |
+
return ptr;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
void free(void *ptr) {
|
| 326 |
+
init_hooks();
|
| 327 |
+
if (g_in_hook || !ptr) { real_free(ptr); return; }
|
| 328 |
+
g_in_hook = 1;
|
| 329 |
+
|
| 330 |
+
int idx = find_allocated(ptr);
|
| 331 |
+
if (idx >= 0) {
|
| 332 |
+
g_chunks[idx].state = 2;
|
| 333 |
+
g_chunks[idx].free_order = ++g_free_seq;
|
| 334 |
+
g_chunks[idx].hash_stable = 0; /* will record post-free hash on next validate */
|
| 335 |
+
} else {
|
| 336 |
+
/* Might be double-free — detect BEFORE calling real_free (glibc may abort) */
|
| 337 |
+
int any = find_chunk(ptr);
|
| 338 |
+
if (any >= 0 && g_chunks[any].state == 2) {
|
| 339 |
+
add_corruption("double_free", any, "freed already-freed chunk");
|
| 340 |
+
/* Dump state with corruption BEFORE glibc aborts */
|
| 341 |
+
dump_state("free_double", ptr, 0);
|
| 342 |
+
if (g_chunk_count < MAX_CHUNKS) {
|
| 343 |
+
int slot = g_chunk_count++;
|
| 344 |
+
g_chunks[slot] = g_chunks[any];
|
| 345 |
+
g_chunks[slot].state = 2;
|
| 346 |
+
g_chunks[slot].free_order = ++g_free_seq;
|
| 347 |
+
g_chunks[slot].hash_stable = 0;
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
real_free(ptr);
|
| 353 |
+
dump_state("free", ptr, 0);
|
| 354 |
+
g_in_hook = 0;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
void *calloc(size_t nmemb, size_t size) {
|
| 358 |
+
init_hooks();
|
| 359 |
+
if (!real_calloc) {
|
| 360 |
+
if (early_buf_used + nmemb * size <= sizeof(early_buf)) {
|
| 361 |
+
void *p = early_buf + early_buf_used;
|
| 362 |
+
early_buf_used += nmemb * size;
|
| 363 |
+
memset(p, 0, nmemb * size);
|
| 364 |
+
return p;
|
| 365 |
+
}
|
| 366 |
+
return NULL;
|
| 367 |
+
}
|
| 368 |
+
if (g_in_hook) return real_calloc(nmemb, size);
|
| 369 |
+
g_in_hook = 1;
|
| 370 |
+
void *ptr = real_calloc(nmemb, size);
|
| 371 |
+
if (ptr) add_chunk(ptr, nmemb * size);
|
| 372 |
+
dump_state("calloc", ptr, nmemb * size);
|
| 373 |
+
g_in_hook = 0;
|
| 374 |
+
return ptr;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
void *realloc(void *old_ptr, size_t size) {
|
| 378 |
+
init_hooks();
|
| 379 |
+
if (g_in_hook) return real_realloc(old_ptr, size);
|
| 380 |
+
g_in_hook = 1;
|
| 381 |
+
if (old_ptr) {
|
| 382 |
+
int idx = find_allocated(old_ptr);
|
| 383 |
+
if (idx >= 0) g_chunks[idx].state = 0;
|
| 384 |
+
}
|
| 385 |
+
void *ptr = real_realloc(old_ptr, size);
|
| 386 |
+
if (ptr) add_chunk(ptr, size);
|
| 387 |
+
dump_state("realloc", ptr, size);
|
| 388 |
+
g_in_hook = 0;
|
| 389 |
+
return ptr;
|
| 390 |
+
}
|
heaptrm/integrations/__init__.py
ADDED
|
File without changes
|
heaptrm/integrations/pwntools.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pwntools.py - HeapTRM integration with pwntools.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from pwn import *
|
| 6 |
+
from heaptrm.integrations.pwntools import HeapWatch
|
| 7 |
+
|
| 8 |
+
# Option 1: Launch with monitoring
|
| 9 |
+
p = HeapWatch.process("./vuln_binary")
|
| 10 |
+
p.sendline(b"1") # interact normally
|
| 11 |
+
result = p.heap_check()
|
| 12 |
+
print(result.verdict) # "EXPLOIT", "SUSPICIOUS", or "CLEAN"
|
| 13 |
+
print(result.corruptions) # list of corruption events
|
| 14 |
+
|
| 15 |
+
# Option 2: Attach to existing process
|
| 16 |
+
p = process("./vuln_binary")
|
| 17 |
+
hw = HeapWatch(p)
|
| 18 |
+
p.sendline(b"exploit payload")
|
| 19 |
+
result = hw.check()
|
| 20 |
+
|
| 21 |
+
# Option 3: Continuous monitoring
|
| 22 |
+
p = HeapWatch.process("./vuln_binary")
|
| 23 |
+
p.sendline(b"1")
|
| 24 |
+
for event in p.heap_stream():
|
| 25 |
+
if event.verdict == "EXPLOIT":
|
| 26 |
+
print(f"Exploit detected at step {event.step}!")
|
| 27 |
+
break
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import os
|
| 31 |
+
import json
|
| 32 |
+
import tempfile
|
| 33 |
+
import subprocess
|
| 34 |
+
import numpy as np
|
| 35 |
+
import torch
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from dataclasses import dataclass
|
| 38 |
+
from typing import List, Optional, Iterator
|
| 39 |
+
|
| 40 |
+
# Find the heaptrm package root
|
| 41 |
+
_PKG_ROOT = Path(__file__).parent.parent
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class HeapEvent:
|
| 46 |
+
step: int
|
| 47 |
+
verdict: str # "EXPLOIT", "SUSPICIOUS", "CLEAN"
|
| 48 |
+
confidence: float
|
| 49 |
+
corruptions: list
|
| 50 |
+
n_chunks: int
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class HeapWatch:
|
| 54 |
+
"""pwntools-compatible heap monitoring wrapper."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, process=None, model_path=None):
|
| 57 |
+
"""
|
| 58 |
+
Attach HeapWatch to a pwntools process.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
process: a pwntools process (or tube)
|
| 62 |
+
model_path: path to trained .pt model file (optional)
|
| 63 |
+
"""
|
| 64 |
+
self._proc = process
|
| 65 |
+
self._dump_path = None
|
| 66 |
+
self._model = None
|
| 67 |
+
self._model_path = model_path
|
| 68 |
+
self._harness_path = self._find_harness()
|
| 69 |
+
self._last_read_pos = 0
|
| 70 |
+
|
| 71 |
+
def _find_harness(self):
|
| 72 |
+
candidates = [
|
| 73 |
+
_PKG_ROOT / "harness" / "heapgrid_v2.so",
|
| 74 |
+
_PKG_ROOT.parent / "harness" / "heapgrid_harness.so",
|
| 75 |
+
]
|
| 76 |
+
for p in candidates:
|
| 77 |
+
if p.exists():
|
| 78 |
+
return str(p.resolve())
|
| 79 |
+
|
| 80 |
+
# Try to compile
|
| 81 |
+
src = _PKG_ROOT / "harness" / "heapgrid_v2.c"
|
| 82 |
+
if src.exists():
|
| 83 |
+
out = src.with_suffix(".so")
|
| 84 |
+
subprocess.run(
|
| 85 |
+
["gcc", "-shared", "-fPIC", "-O2", "-o", str(out), str(src),
|
| 86 |
+
"-ldl", "-pthread"],
|
| 87 |
+
capture_output=True
|
| 88 |
+
)
|
| 89 |
+
if out.exists():
|
| 90 |
+
return str(out.resolve())
|
| 91 |
+
|
| 92 |
+
raise FileNotFoundError("Cannot find heapgrid harness .so")
|
| 93 |
+
|
| 94 |
+
def _load_model(self):
|
| 95 |
+
if self._model is not None:
|
| 96 |
+
return self._model
|
| 97 |
+
|
| 98 |
+
from ..classifier.model import HeapTRM
|
| 99 |
+
self._model = HeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 100 |
+
|
| 101 |
+
if self._model_path and Path(self._model_path).exists():
|
| 102 |
+
device = torch.device("cpu")
|
| 103 |
+
self._model.load_state_dict(
|
| 104 |
+
torch.load(self._model_path, map_location=device, weights_only=True))
|
| 105 |
+
|
| 106 |
+
self._model.eval()
|
| 107 |
+
return self._model
|
| 108 |
+
|
| 109 |
+
@classmethod
|
| 110 |
+
def process(cls, binary, args=None, model_path=None, **kwargs):
|
| 111 |
+
"""
|
| 112 |
+
Launch a process with heap monitoring enabled.
|
| 113 |
+
|
| 114 |
+
Returns a pwntools process with .heap_check() and .heap_stream() methods.
|
| 115 |
+
|
| 116 |
+
Requires pwntools to be installed.
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
from pwn import process as pwn_process
|
| 120 |
+
except ImportError:
|
| 121 |
+
raise ImportError("pwntools required: pip install pwntools")
|
| 122 |
+
|
| 123 |
+
dump_path = tempfile.mktemp(suffix=".heaptrm.jsonl")
|
| 124 |
+
|
| 125 |
+
# Set up environment for LD_PRELOAD
|
| 126 |
+
env = kwargs.pop("env", os.environ.copy())
|
| 127 |
+
hw = cls(model_path=model_path)
|
| 128 |
+
env["LD_PRELOAD"] = hw._harness_path
|
| 129 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 130 |
+
|
| 131 |
+
cmd = [binary] + (args or [])
|
| 132 |
+
proc = pwn_process(cmd, env=env, **kwargs)
|
| 133 |
+
|
| 134 |
+
# Attach HeapWatch
|
| 135 |
+
hw._proc = proc
|
| 136 |
+
hw._dump_path = dump_path
|
| 137 |
+
|
| 138 |
+
# Monkey-patch convenience methods onto the process
|
| 139 |
+
proc._heapwatch = hw
|
| 140 |
+
proc.heap_check = hw.check
|
| 141 |
+
proc.heap_stream = hw.stream
|
| 142 |
+
|
| 143 |
+
return proc
|
| 144 |
+
|
| 145 |
+
def check(self) -> HeapEvent:
|
| 146 |
+
"""Check current heap state. Returns latest HeapEvent."""
|
| 147 |
+
states = self._read_new_states()
|
| 148 |
+
if not states:
|
| 149 |
+
return HeapEvent(0, "CLEAN", 0.0, [], 0)
|
| 150 |
+
|
| 151 |
+
latest = states[-1]
|
| 152 |
+
corruptions = latest.get("corruptions", [])
|
| 153 |
+
n_chunks = len(latest.get("chunks", []))
|
| 154 |
+
step = latest.get("step", 0)
|
| 155 |
+
|
| 156 |
+
# Rule-based detection (corruption events from v2 harness)
|
| 157 |
+
has_corruption = len(corruptions) > 0
|
| 158 |
+
|
| 159 |
+
# ML-based detection (TRM classifier)
|
| 160 |
+
confidence = 0.0
|
| 161 |
+
try:
|
| 162 |
+
from ..classifier.grid import state_to_grid
|
| 163 |
+
model = self._load_model()
|
| 164 |
+
grid = state_to_grid(latest)
|
| 165 |
+
x = torch.from_numpy(grid).long().unsqueeze(0)
|
| 166 |
+
with torch.no_grad():
|
| 167 |
+
logits = model(x)
|
| 168 |
+
prob = torch.softmax(logits, dim=1)[0, 1].item()
|
| 169 |
+
confidence = prob
|
| 170 |
+
except Exception:
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
# Combined verdict
|
| 174 |
+
if has_corruption:
|
| 175 |
+
verdict = "EXPLOIT"
|
| 176 |
+
confidence = max(confidence, 0.95)
|
| 177 |
+
elif confidence >= 0.7:
|
| 178 |
+
verdict = "EXPLOIT"
|
| 179 |
+
elif confidence >= 0.3:
|
| 180 |
+
verdict = "SUSPICIOUS"
|
| 181 |
+
else:
|
| 182 |
+
verdict = "CLEAN"
|
| 183 |
+
|
| 184 |
+
return HeapEvent(step, verdict, confidence, corruptions, n_chunks)
|
| 185 |
+
|
| 186 |
+
def stream(self) -> Iterator[HeapEvent]:
|
| 187 |
+
"""
|
| 188 |
+
Yield HeapEvents as they occur.
|
| 189 |
+
|
| 190 |
+
Usage:
|
| 191 |
+
for event in hw.stream():
|
| 192 |
+
if event.verdict == "EXPLOIT":
|
| 193 |
+
break
|
| 194 |
+
"""
|
| 195 |
+
import time
|
| 196 |
+
while True:
|
| 197 |
+
event = self.check()
|
| 198 |
+
if event.step > 0:
|
| 199 |
+
yield event
|
| 200 |
+
if self._proc and hasattr(self._proc, 'poll'):
|
| 201 |
+
if self._proc.poll() is not None:
|
| 202 |
+
break
|
| 203 |
+
time.sleep(0.05)
|
| 204 |
+
|
| 205 |
+
def _read_new_states(self) -> list:
|
| 206 |
+
"""Read new states from the dump file since last check."""
|
| 207 |
+
if not self._dump_path:
|
| 208 |
+
# Try default path
|
| 209 |
+
self._dump_path = os.environ.get("HEAPGRID_OUT", "heap_dump.jsonl")
|
| 210 |
+
|
| 211 |
+
if not os.path.exists(self._dump_path):
|
| 212 |
+
return []
|
| 213 |
+
|
| 214 |
+
states = []
|
| 215 |
+
try:
|
| 216 |
+
with open(self._dump_path) as f:
|
| 217 |
+
f.seek(self._last_read_pos)
|
| 218 |
+
for line in f:
|
| 219 |
+
if line.strip():
|
| 220 |
+
states.append(json.loads(line.strip()))
|
| 221 |
+
self._last_read_pos = f.tell()
|
| 222 |
+
except Exception:
|
| 223 |
+
pass
|
| 224 |
+
|
| 225 |
+
return states
|
| 226 |
+
|
| 227 |
+
def summary(self) -> dict:
|
| 228 |
+
"""Get summary of all heap events observed."""
|
| 229 |
+
if not self._dump_path or not os.path.exists(self._dump_path):
|
| 230 |
+
return {"states": 0, "corruptions": 0}
|
| 231 |
+
|
| 232 |
+
states = []
|
| 233 |
+
with open(self._dump_path) as f:
|
| 234 |
+
for line in f:
|
| 235 |
+
if line.strip():
|
| 236 |
+
states.append(json.loads(line.strip()))
|
| 237 |
+
|
| 238 |
+
total_corruptions = sum(
|
| 239 |
+
len(s.get("corruptions", [])) for s in states
|
| 240 |
+
)
|
| 241 |
+
corruption_types = set()
|
| 242 |
+
for s in states:
|
| 243 |
+
for c in s.get("corruptions", []):
|
| 244 |
+
corruption_types.add(c.get("type", "unknown"))
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"states": len(states),
|
| 248 |
+
"corruptions": total_corruptions,
|
| 249 |
+
"corruption_types": list(corruption_types),
|
| 250 |
+
"final_chunks": len(states[-1].get("chunks", [])) if states else 0,
|
| 251 |
+
}
|
heaptrm/monitor.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
monitor.py - HeapMonitor: the main user-facing API.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from heaptrm import HeapMonitor
|
| 6 |
+
|
| 7 |
+
# Scan a binary
|
| 8 |
+
m = HeapMonitor()
|
| 9 |
+
result = m.scan("./target", args=["arg1"])
|
| 10 |
+
print(result.verdict) # "EXPLOIT" or "CLEAN"
|
| 11 |
+
print(result.confidence) # 0.0-1.0
|
| 12 |
+
print(result.corruptions) # list of detected corruption events
|
| 13 |
+
|
| 14 |
+
# Attach to pwntools process
|
| 15 |
+
from pwn import process
|
| 16 |
+
p = process("./target")
|
| 17 |
+
m = HeapMonitor.attach(p)
|
| 18 |
+
m.check() # check current heap state
|
| 19 |
+
|
| 20 |
+
# Live monitoring
|
| 21 |
+
m = HeapMonitor.live("./target")
|
| 22 |
+
for event in m.stream():
|
| 23 |
+
print(event) # real-time heap events
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import subprocess
|
| 29 |
+
import tempfile
|
| 30 |
+
import time
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from dataclasses import dataclass, field
|
| 35 |
+
from typing import List, Optional
|
| 36 |
+
|
| 37 |
+
from .classifier.model import HeapTRM
|
| 38 |
+
from .classifier.grid import state_to_grid, load_dump
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class CorruptionEvent:
|
| 43 |
+
step: int
|
| 44 |
+
type: str # "metadata_corrupt", "uaf_write", "double_free", "overflow"
|
| 45 |
+
chunk_idx: int
|
| 46 |
+
detail: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class ScanResult:
|
| 51 |
+
verdict: str # "EXPLOIT", "SUSPICIOUS", "CLEAN"
|
| 52 |
+
confidence: float # max exploit probability
|
| 53 |
+
n_states: int # total heap states observed
|
| 54 |
+
n_flagged: int # states classified as exploit
|
| 55 |
+
corruptions: List[CorruptionEvent] # detected corruption events
|
| 56 |
+
exploit_states: List[int] # indices of flagged states
|
| 57 |
+
raw_probs: List[float] = field(default_factory=list)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HeapMonitor:
|
| 61 |
+
"""Heap exploit monitor using TRM classifier + LD_PRELOAD instrumentation."""
|
| 62 |
+
|
| 63 |
+
# Thresholds
|
| 64 |
+
EXPLOIT_THRESHOLD = 0.7
|
| 65 |
+
SUSPICIOUS_THRESHOLD = 0.3
|
| 66 |
+
|
| 67 |
+
def __init__(self, model_path: Optional[str] = None, device: str = "auto"):
|
| 68 |
+
"""Initialize with optional pre-trained model."""
|
| 69 |
+
if device == "auto":
|
| 70 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 71 |
+
else:
|
| 72 |
+
self.device = torch.device(device)
|
| 73 |
+
|
| 74 |
+
self.model = HeapTRM(hidden_dim=128, n_outer=2, n_inner=3)
|
| 75 |
+
|
| 76 |
+
if model_path and Path(model_path).exists():
|
| 77 |
+
self.model.load_state_dict(
|
| 78 |
+
torch.load(model_path, map_location=self.device, weights_only=True))
|
| 79 |
+
|
| 80 |
+
self.model.to(self.device).eval()
|
| 81 |
+
|
| 82 |
+
# Find harness .so
|
| 83 |
+
self._harness_path = self._find_harness()
|
| 84 |
+
|
| 85 |
+
def _find_harness(self) -> str:
|
| 86 |
+
"""Locate the compiled harness .so file."""
|
| 87 |
+
candidates = [
|
| 88 |
+
Path(__file__).parent / "harness" / "heapgrid_v2.so",
|
| 89 |
+
Path(__file__).parent.parent / "harness" / "heapgrid_harness.so",
|
| 90 |
+
]
|
| 91 |
+
for p in candidates:
|
| 92 |
+
if p.exists():
|
| 93 |
+
return str(p.resolve())
|
| 94 |
+
|
| 95 |
+
# Try to compile it
|
| 96 |
+
src = Path(__file__).parent / "harness" / "heapgrid_v2.c"
|
| 97 |
+
if src.exists():
|
| 98 |
+
out = src.with_suffix(".so")
|
| 99 |
+
subprocess.run(
|
| 100 |
+
["gcc", "-shared", "-fPIC", "-O2", "-o", str(out), str(src),
|
| 101 |
+
"-ldl", "-pthread"],
|
| 102 |
+
capture_output=True
|
| 103 |
+
)
|
| 104 |
+
if out.exists():
|
| 105 |
+
return str(out.resolve())
|
| 106 |
+
|
| 107 |
+
raise FileNotFoundError("Could not find or build heapgrid harness")
|
| 108 |
+
|
| 109 |
+
def scan(self, binary: str, args: list = None,
|
| 110 |
+
stdin_data: bytes = None, timeout: int = 30) -> ScanResult:
|
| 111 |
+
"""
|
| 112 |
+
Run a binary with heap instrumentation and classify its heap behavior.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
binary: path to the target binary
|
| 116 |
+
args: command line arguments
|
| 117 |
+
stdin_data: data to pipe to stdin
|
| 118 |
+
timeout: max runtime in seconds
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
ScanResult with verdict, confidence, and corruption events
|
| 122 |
+
"""
|
| 123 |
+
dump_path = tempfile.mktemp(suffix=".jsonl")
|
| 124 |
+
|
| 125 |
+
env = os.environ.copy()
|
| 126 |
+
env["LD_PRELOAD"] = self._harness_path
|
| 127 |
+
env["HEAPGRID_OUT"] = dump_path
|
| 128 |
+
|
| 129 |
+
cmd = [binary] + (args or [])
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
proc = subprocess.run(
|
| 133 |
+
cmd, input=stdin_data, env=env,
|
| 134 |
+
capture_output=True, timeout=timeout
|
| 135 |
+
)
|
| 136 |
+
except subprocess.TimeoutExpired:
|
| 137 |
+
pass
|
| 138 |
+
|
| 139 |
+
# Load and analyze dump
|
| 140 |
+
states = []
|
| 141 |
+
if os.path.exists(dump_path):
|
| 142 |
+
states = load_dump(Path(dump_path))
|
| 143 |
+
os.unlink(dump_path)
|
| 144 |
+
|
| 145 |
+
return self._analyze(states)
|
| 146 |
+
|
| 147 |
+
def analyze_dump(self, dump_path: str) -> ScanResult:
|
| 148 |
+
"""Analyze a pre-existing heap dump file."""
|
| 149 |
+
states = load_dump(Path(dump_path))
|
| 150 |
+
return self._analyze(states)
|
| 151 |
+
|
| 152 |
+
def _analyze(self, states: list) -> ScanResult:
|
| 153 |
+
"""Classify a sequence of heap states."""
|
| 154 |
+
if not states:
|
| 155 |
+
return ScanResult("CLEAN", 0.0, 0, 0, [], [])
|
| 156 |
+
|
| 157 |
+
# Extract corruption events from v2 harness data
|
| 158 |
+
corruptions = []
|
| 159 |
+
for state in states:
|
| 160 |
+
for c in state.get("corruptions", []):
|
| 161 |
+
corruptions.append(CorruptionEvent(
|
| 162 |
+
step=state.get("step", 0),
|
| 163 |
+
type=c.get("type", "unknown"),
|
| 164 |
+
chunk_idx=c.get("chunk_idx", -1),
|
| 165 |
+
detail=c.get("detail", ""),
|
| 166 |
+
))
|
| 167 |
+
|
| 168 |
+
# Encode to grids and classify
|
| 169 |
+
grids = np.stack([state_to_grid(s) for s in states])
|
| 170 |
+
X = torch.from_numpy(grids).long().to(self.device)
|
| 171 |
+
|
| 172 |
+
with torch.no_grad():
|
| 173 |
+
logits = self.model(X)
|
| 174 |
+
probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy()
|
| 175 |
+
preds = logits.argmax(dim=1).cpu().numpy()
|
| 176 |
+
|
| 177 |
+
max_prob = float(probs.max())
|
| 178 |
+
flagged = [int(i) for i in range(len(preds)) if preds[i] == 1]
|
| 179 |
+
|
| 180 |
+
# Determine verdict
|
| 181 |
+
# Corruption events are strong signal regardless of classifier
|
| 182 |
+
has_corruption = len(corruptions) > 0
|
| 183 |
+
|
| 184 |
+
if has_corruption or max_prob >= self.EXPLOIT_THRESHOLD:
|
| 185 |
+
verdict = "EXPLOIT"
|
| 186 |
+
elif max_prob >= self.SUSPICIOUS_THRESHOLD:
|
| 187 |
+
verdict = "SUSPICIOUS"
|
| 188 |
+
else:
|
| 189 |
+
verdict = "CLEAN"
|
| 190 |
+
|
| 191 |
+
# Boost confidence if corruptions detected
|
| 192 |
+
confidence = max_prob
|
| 193 |
+
if has_corruption:
|
| 194 |
+
confidence = max(confidence, 0.9)
|
| 195 |
+
|
| 196 |
+
return ScanResult(
|
| 197 |
+
verdict=verdict,
|
| 198 |
+
confidence=confidence,
|
| 199 |
+
n_states=len(states),
|
| 200 |
+
n_flagged=len(flagged),
|
| 201 |
+
corruptions=corruptions,
|
| 202 |
+
exploit_states=flagged,
|
| 203 |
+
raw_probs=probs.tolist(),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
@classmethod
|
| 207 |
+
def attach(cls, process, **kwargs):
|
| 208 |
+
"""
|
| 209 |
+
Attach to a pwntools process.
|
| 210 |
+
|
| 211 |
+
Usage:
|
| 212 |
+
from pwn import process
|
| 213 |
+
p = process("./target")
|
| 214 |
+
m = HeapMonitor.attach(p)
|
| 215 |
+
"""
|
| 216 |
+
# pwntools integration: read from process's heap dump
|
| 217 |
+
monitor = cls(**kwargs)
|
| 218 |
+
monitor._pwntools_proc = process
|
| 219 |
+
return monitor
|
| 220 |
+
|
| 221 |
+
def check(self) -> ScanResult:
|
| 222 |
+
"""Check the attached pwntools process's current heap state."""
|
| 223 |
+
if not hasattr(self, "_pwntools_proc"):
|
| 224 |
+
raise RuntimeError("No process attached. Use HeapMonitor.attach()")
|
| 225 |
+
# Read the dump file
|
| 226 |
+
dump_path = os.environ.get("HEAPGRID_OUT", "heap_dump.jsonl")
|
| 227 |
+
if os.path.exists(dump_path):
|
| 228 |
+
return self.analyze_dump(dump_path)
|
| 229 |
+
return ScanResult("CLEAN", 0.0, 0, 0, [], [])
|
model/trm_heap.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
trm_heap.py - Tiny Recursive Model for heap exploitation reasoning.
|
| 3 |
+
|
| 4 |
+
Based on "Less is More: Recursive Reasoning with Tiny Networks"
|
| 5 |
+
(arxiv 2510.04871) by Alexia Jolicoeur-Martineau.
|
| 6 |
+
|
| 7 |
+
Architecture:
|
| 8 |
+
- Token embedding for grid cells (vocab_size=64)
|
| 9 |
+
- Learnable initial states y_init, z_init
|
| 10 |
+
- 2-layer recursive MLP with SwiGLU + RMSNorm
|
| 11 |
+
- Deep supervision: loss computed at each outer iteration
|
| 12 |
+
- Output: binary classification (exploit-reachable or not)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
import numpy as np
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Optional, Tuple
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class RMSNorm(nn.Module):
|
| 24 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 25 |
+
super().__init__()
|
| 26 |
+
self.eps = eps
|
| 27 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 28 |
+
|
| 29 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 30 |
+
norm = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
|
| 31 |
+
return x * norm * self.weight
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SwiGLU(nn.Module):
|
| 35 |
+
def __init__(self, dim: int):
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.w1 = nn.Linear(dim, dim * 2, bias=False)
|
| 38 |
+
self.w2 = nn.Linear(dim, dim, bias=False)
|
| 39 |
+
|
| 40 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 41 |
+
gate, val = self.w1(x).chunk(2, dim=-1)
|
| 42 |
+
return self.w2(F.silu(gate) * val)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RecursionBlock(nn.Module):
|
| 46 |
+
"""Single recursive processing block: Linear -> SwiGLU -> RMSNorm -> Linear"""
|
| 47 |
+
def __init__(self, dim: int):
|
| 48 |
+
super().__init__()
|
| 49 |
+
self.norm1 = RMSNorm(dim)
|
| 50 |
+
self.swiglu = SwiGLU(dim)
|
| 51 |
+
self.norm2 = RMSNorm(dim)
|
| 52 |
+
|
| 53 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 54 |
+
x = self.norm1(x)
|
| 55 |
+
x = self.swiglu(x)
|
| 56 |
+
x = self.norm2(x)
|
| 57 |
+
return x
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HeapTRM(nn.Module):
|
| 61 |
+
"""
|
| 62 |
+
Tiny Recursive Model for heap state classification.
|
| 63 |
+
|
| 64 |
+
Input: (batch, 32, 16) integer grid
|
| 65 |
+
Output: (batch, 2) logits for [not_exploitable, exploitable]
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
vocab_size: int = 64,
|
| 71 |
+
hidden_dim: int = 128,
|
| 72 |
+
seq_len: int = 512, # 32 * 16
|
| 73 |
+
n_outer: int = 3, # T in the paper
|
| 74 |
+
n_inner: int = 6, # n in the paper
|
| 75 |
+
n_classes: int = 2,
|
| 76 |
+
):
|
| 77 |
+
super().__init__()
|
| 78 |
+
self.seq_len = seq_len
|
| 79 |
+
self.n_outer = n_outer
|
| 80 |
+
self.n_inner = n_inner
|
| 81 |
+
self.hidden_dim = hidden_dim
|
| 82 |
+
|
| 83 |
+
# Token embedding
|
| 84 |
+
self.embed = nn.Embedding(vocab_size, hidden_dim)
|
| 85 |
+
|
| 86 |
+
# Learnable initial hidden states
|
| 87 |
+
self.y_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 88 |
+
self.z_init = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 89 |
+
|
| 90 |
+
# Shared recursive blocks (2 layers, as per paper)
|
| 91 |
+
self.block_z = RecursionBlock(hidden_dim)
|
| 92 |
+
self.block_y = RecursionBlock(hidden_dim)
|
| 93 |
+
|
| 94 |
+
# Output head
|
| 95 |
+
self.out_norm = RMSNorm(hidden_dim)
|
| 96 |
+
self.out_head = nn.Linear(hidden_dim, n_classes)
|
| 97 |
+
|
| 98 |
+
# Position embedding for grid structure
|
| 99 |
+
self.pos_embed = nn.Parameter(torch.randn(1, seq_len, hidden_dim) * 0.02)
|
| 100 |
+
|
| 101 |
+
def forward(
|
| 102 |
+
self,
|
| 103 |
+
x: torch.Tensor,
|
| 104 |
+
return_intermediate: bool = False,
|
| 105 |
+
) -> torch.Tensor:
|
| 106 |
+
"""
|
| 107 |
+
Args:
|
| 108 |
+
x: (batch, 32, 16) integer grid
|
| 109 |
+
return_intermediate: if True, return logits at each outer step
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
logits: (batch, n_classes) or list of (batch, n_classes) if intermediate
|
| 113 |
+
"""
|
| 114 |
+
B = x.shape[0]
|
| 115 |
+
|
| 116 |
+
# Flatten grid to sequence: (B, 32, 16) -> (B, 512)
|
| 117 |
+
x_flat = x.reshape(B, -1)
|
| 118 |
+
|
| 119 |
+
# Embed tokens + positional
|
| 120 |
+
h = self.embed(x_flat) + self.pos_embed # (B, 512, hidden_dim)
|
| 121 |
+
|
| 122 |
+
# Initialize recurrent states
|
| 123 |
+
y = self.y_init.expand(B, -1, -1)
|
| 124 |
+
z = self.z_init.expand(B, -1, -1)
|
| 125 |
+
|
| 126 |
+
intermediates = []
|
| 127 |
+
|
| 128 |
+
# Recursive processing
|
| 129 |
+
for t in range(self.n_outer):
|
| 130 |
+
for i in range(self.n_inner):
|
| 131 |
+
z = z + self.block_z(h + y + z) # residual connection
|
| 132 |
+
y = y + self.block_y(y + z) # residual connection
|
| 133 |
+
|
| 134 |
+
if return_intermediate or t == self.n_outer - 1:
|
| 135 |
+
# Compute output at this outer iteration
|
| 136 |
+
pooled = self.out_norm(y).mean(dim=1) # (B, hidden_dim)
|
| 137 |
+
logits = self.out_head(pooled) # (B, n_classes)
|
| 138 |
+
intermediates.append(logits)
|
| 139 |
+
|
| 140 |
+
if return_intermediate:
|
| 141 |
+
return intermediates
|
| 142 |
+
return intermediates[-1]
|
| 143 |
+
|
| 144 |
+
def count_params(self) -> int:
|
| 145 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# --- Training utilities ---
|
| 149 |
+
|
| 150 |
+
class HeapDataset(torch.utils.data.Dataset):
|
| 151 |
+
def __init__(self, X: np.ndarray, y: np.ndarray):
|
| 152 |
+
self.X = torch.from_numpy(X).long()
|
| 153 |
+
self.y = torch.from_numpy(y).long()
|
| 154 |
+
|
| 155 |
+
def __len__(self):
|
| 156 |
+
return len(self.X)
|
| 157 |
+
|
| 158 |
+
def __getitem__(self, idx):
|
| 159 |
+
return self.X[idx], self.y[idx]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def focal_loss(logits, targets, alpha=0.75, gamma=2.0):
|
| 163 |
+
"""Focal loss for handling class imbalance. Alpha weights the positive class."""
|
| 164 |
+
ce = F.cross_entropy(logits, targets, reduction='none')
|
| 165 |
+
pt = torch.exp(-ce)
|
| 166 |
+
# Apply alpha weighting: alpha for positive, (1-alpha) for negative
|
| 167 |
+
alpha_t = torch.where(targets == 1, alpha, 1 - alpha)
|
| 168 |
+
loss = alpha_t * (1 - pt) ** gamma * ce
|
| 169 |
+
return loss.mean()
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def train_epoch(
|
| 173 |
+
model: HeapTRM,
|
| 174 |
+
loader: torch.utils.data.DataLoader,
|
| 175 |
+
optimizer: torch.optim.Optimizer,
|
| 176 |
+
device: torch.device,
|
| 177 |
+
deep_supervision: bool = True,
|
| 178 |
+
use_focal: bool = False,
|
| 179 |
+
) -> float:
|
| 180 |
+
model.train()
|
| 181 |
+
total_loss = 0.0
|
| 182 |
+
n_batches = 0
|
| 183 |
+
|
| 184 |
+
loss_fn = focal_loss if use_focal else F.cross_entropy
|
| 185 |
+
|
| 186 |
+
for X_batch, y_batch in loader:
|
| 187 |
+
X_batch = X_batch.to(device)
|
| 188 |
+
y_batch = y_batch.to(device)
|
| 189 |
+
|
| 190 |
+
optimizer.zero_grad()
|
| 191 |
+
|
| 192 |
+
if deep_supervision:
|
| 193 |
+
intermediates = model(X_batch, return_intermediate=True)
|
| 194 |
+
# Loss at each outer step, weighted by recency
|
| 195 |
+
loss = 0.0
|
| 196 |
+
for t, logits in enumerate(intermediates):
|
| 197 |
+
weight = (t + 1) / len(intermediates) # later steps weighted more
|
| 198 |
+
loss += weight * loss_fn(logits, y_batch)
|
| 199 |
+
loss /= len(intermediates)
|
| 200 |
+
else:
|
| 201 |
+
logits = model(X_batch)
|
| 202 |
+
loss = loss_fn(logits, y_batch)
|
| 203 |
+
|
| 204 |
+
loss.backward()
|
| 205 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 206 |
+
optimizer.step()
|
| 207 |
+
|
| 208 |
+
total_loss += loss.item()
|
| 209 |
+
n_batches += 1
|
| 210 |
+
|
| 211 |
+
return total_loss / max(n_batches, 1)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@torch.no_grad()
|
| 215 |
+
def evaluate(
|
| 216 |
+
model: HeapTRM,
|
| 217 |
+
loader: torch.utils.data.DataLoader,
|
| 218 |
+
device: torch.device,
|
| 219 |
+
) -> dict:
|
| 220 |
+
model.eval()
|
| 221 |
+
all_preds = []
|
| 222 |
+
all_labels = []
|
| 223 |
+
total_loss = 0.0
|
| 224 |
+
n_batches = 0
|
| 225 |
+
|
| 226 |
+
for X_batch, y_batch in loader:
|
| 227 |
+
X_batch = X_batch.to(device)
|
| 228 |
+
y_batch = y_batch.to(device)
|
| 229 |
+
|
| 230 |
+
logits = model(X_batch)
|
| 231 |
+
loss = F.cross_entropy(logits, y_batch)
|
| 232 |
+
|
| 233 |
+
preds = logits.argmax(dim=1)
|
| 234 |
+
all_preds.append(preds.cpu())
|
| 235 |
+
all_labels.append(y_batch.cpu())
|
| 236 |
+
total_loss += loss.item()
|
| 237 |
+
n_batches += 1
|
| 238 |
+
|
| 239 |
+
all_preds = torch.cat(all_preds)
|
| 240 |
+
all_labels = torch.cat(all_labels)
|
| 241 |
+
|
| 242 |
+
tp = ((all_preds == 1) & (all_labels == 1)).sum().item()
|
| 243 |
+
fp = ((all_preds == 1) & (all_labels == 0)).sum().item()
|
| 244 |
+
fn = ((all_preds == 0) & (all_labels == 1)).sum().item()
|
| 245 |
+
tn = ((all_preds == 0) & (all_labels == 0)).sum().item()
|
| 246 |
+
|
| 247 |
+
accuracy = (tp + tn) / max(tp + tn + fp + fn, 1)
|
| 248 |
+
precision = tp / max(tp + fp, 1)
|
| 249 |
+
recall = tp / max(tp + fn, 1)
|
| 250 |
+
f1 = 2 * precision * recall / max(precision + recall, 1e-8)
|
| 251 |
+
|
| 252 |
+
return {
|
| 253 |
+
"loss": total_loss / max(n_batches, 1),
|
| 254 |
+
"accuracy": accuracy,
|
| 255 |
+
"precision": precision,
|
| 256 |
+
"recall": recall,
|
| 257 |
+
"f1": f1,
|
| 258 |
+
"tp": tp, "fp": fp, "fn": fn, "tn": tn,
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def train_model(
|
| 263 |
+
data_dir: Path,
|
| 264 |
+
output_dir: Path,
|
| 265 |
+
hidden_dim: int = 128,
|
| 266 |
+
n_outer: int = 3,
|
| 267 |
+
n_inner: int = 6,
|
| 268 |
+
epochs: int = 100,
|
| 269 |
+
batch_size: int = 32,
|
| 270 |
+
lr: float = 3e-4,
|
| 271 |
+
device_str: str = "auto",
|
| 272 |
+
use_focal: bool = False,
|
| 273 |
+
):
|
| 274 |
+
"""Full training pipeline."""
|
| 275 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 276 |
+
|
| 277 |
+
# Load data
|
| 278 |
+
X_train = np.load(data_dir / "X_train.npy")
|
| 279 |
+
y_train = np.load(data_dir / "y_train.npy")
|
| 280 |
+
X_test = np.load(data_dir / "X_test.npy")
|
| 281 |
+
y_test = np.load(data_dir / "y_test.npy")
|
| 282 |
+
|
| 283 |
+
print(f"Train: {X_train.shape[0]} samples ({y_train.sum()} positive)")
|
| 284 |
+
print(f"Test: {X_test.shape[0]} samples ({y_test.sum()} positive)")
|
| 285 |
+
|
| 286 |
+
# Device
|
| 287 |
+
if device_str == "auto":
|
| 288 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 289 |
+
else:
|
| 290 |
+
device = torch.device(device_str)
|
| 291 |
+
print(f"Device: {device}")
|
| 292 |
+
|
| 293 |
+
# DataLoaders
|
| 294 |
+
train_ds = HeapDataset(X_train, y_train)
|
| 295 |
+
test_ds = HeapDataset(X_test, y_test)
|
| 296 |
+
train_loader = torch.utils.data.DataLoader(
|
| 297 |
+
train_ds, batch_size=batch_size, shuffle=True, drop_last=False
|
| 298 |
+
)
|
| 299 |
+
test_loader = torch.utils.data.DataLoader(
|
| 300 |
+
test_ds, batch_size=batch_size, shuffle=False
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
# Model
|
| 304 |
+
model = HeapTRM(
|
| 305 |
+
hidden_dim=hidden_dim,
|
| 306 |
+
n_outer=n_outer,
|
| 307 |
+
n_inner=n_inner,
|
| 308 |
+
).to(device)
|
| 309 |
+
print(f"Parameters: {model.count_params():,}")
|
| 310 |
+
|
| 311 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
|
| 312 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
|
| 313 |
+
|
| 314 |
+
best_f1 = 0.0
|
| 315 |
+
for epoch in range(1, epochs + 1):
|
| 316 |
+
train_loss = train_epoch(model, train_loader, optimizer, device,
|
| 317 |
+
use_focal=use_focal)
|
| 318 |
+
scheduler.step()
|
| 319 |
+
|
| 320 |
+
if epoch % 5 == 0 or epoch == 1:
|
| 321 |
+
metrics = evaluate(model, test_loader, device)
|
| 322 |
+
print(f"Epoch {epoch:3d} | train_loss={train_loss:.4f} | "
|
| 323 |
+
f"test_loss={metrics['loss']:.4f} acc={metrics['accuracy']:.3f} "
|
| 324 |
+
f"prec={metrics['precision']:.3f} rec={metrics['recall']:.3f} "
|
| 325 |
+
f"f1={metrics['f1']:.3f} "
|
| 326 |
+
f"[TP={metrics['tp']} FP={metrics['fp']} FN={metrics['fn']} TN={metrics['tn']}]")
|
| 327 |
+
|
| 328 |
+
if metrics['f1'] > best_f1:
|
| 329 |
+
best_f1 = metrics['f1']
|
| 330 |
+
torch.save(model.state_dict(), output_dir / "best_model.pt")
|
| 331 |
+
print(f" -> New best F1: {best_f1:.3f}")
|
| 332 |
+
|
| 333 |
+
# Final eval
|
| 334 |
+
model.load_state_dict(torch.load(output_dir / "best_model.pt", weights_only=True))
|
| 335 |
+
final = evaluate(model, test_loader, device)
|
| 336 |
+
print(f"\nFinal test metrics: {final}")
|
| 337 |
+
|
| 338 |
+
return model, final
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
if __name__ == "__main__":
|
| 342 |
+
import argparse
|
| 343 |
+
parser = argparse.ArgumentParser()
|
| 344 |
+
parser.add_argument("--data-dir", type=Path, default=Path("data/processed"))
|
| 345 |
+
parser.add_argument("--output-dir", type=Path, default=Path("data/checkpoints"))
|
| 346 |
+
parser.add_argument("--hidden-dim", type=int, default=128)
|
| 347 |
+
parser.add_argument("--n-outer", type=int, default=3)
|
| 348 |
+
parser.add_argument("--n-inner", type=int, default=6)
|
| 349 |
+
parser.add_argument("--epochs", type=int, default=100)
|
| 350 |
+
parser.add_argument("--batch-size", type=int, default=32)
|
| 351 |
+
parser.add_argument("--lr", type=float, default=3e-4)
|
| 352 |
+
args = parser.parse_args()
|
| 353 |
+
train_model(
|
| 354 |
+
args.data_dir, args.output_dir,
|
| 355 |
+
hidden_dim=args.hidden_dim,
|
| 356 |
+
n_outer=args.n_outer, n_inner=args.n_inner,
|
| 357 |
+
epochs=args.epochs, batch_size=args.batch_size, lr=args.lr,
|
| 358 |
+
)
|
runner/augment_data.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
augment_data.py - Generate more training data by:
|
| 4 |
+
1. Running how2heap examples across multiple glibc versions
|
| 5 |
+
2. Adding random noise allocations between steps (synthetic variants)
|
| 6 |
+
|
| 7 |
+
This multiplies the dataset by ~N glibc versions available.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import subprocess
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 15 |
+
HOW2HEAP = ROOT / "how2heap"
|
| 16 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 17 |
+
BIN_DIR = ROOT / "data" / "bins_augmented"
|
| 18 |
+
DUMP_DIR = ROOT / "data" / "dumps"
|
| 19 |
+
|
| 20 |
+
# All glibc versions that have examples
|
| 21 |
+
GLIBC_VERSIONS = ["2.35", "2.36", "2.37", "2.38", "2.39"]
|
| 22 |
+
|
| 23 |
+
# Techniques we want more data for
|
| 24 |
+
TECHNIQUES = [
|
| 25 |
+
"fastbin_dup",
|
| 26 |
+
"fastbin_dup_into_stack",
|
| 27 |
+
"fastbin_dup_consolidate",
|
| 28 |
+
"tcache_poisoning",
|
| 29 |
+
"house_of_spirit",
|
| 30 |
+
"unsafe_unlink",
|
| 31 |
+
"tcache_house_of_spirit",
|
| 32 |
+
"tcache_stashing_unlink_attack",
|
| 33 |
+
"house_of_einherjar",
|
| 34 |
+
"large_bin_attack",
|
| 35 |
+
"poison_null_byte",
|
| 36 |
+
"house_of_lore",
|
| 37 |
+
"house_of_water",
|
| 38 |
+
"house_of_botcake",
|
| 39 |
+
"overlapping_chunks",
|
| 40 |
+
"fastbin_reverse_into_tcache",
|
| 41 |
+
"house_of_mind_fastbin",
|
| 42 |
+
"tcache_relative_write",
|
| 43 |
+
"safe_link_double_protect",
|
| 44 |
+
"house_of_tangerine",
|
| 45 |
+
"decrypt_safe_linking",
|
| 46 |
+
"mmap_overlapping_chunks",
|
| 47 |
+
"tcache_metadata_poisoning",
|
| 48 |
+
"sysmalloc_int_free",
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def compile_and_run(technique: str, glibc_ver: str, suffix: str = ""):
|
| 53 |
+
"""Compile and instrument one technique from one glibc version."""
|
| 54 |
+
src = HOW2HEAP / f"glibc_{glibc_ver}" / f"{technique}.c"
|
| 55 |
+
if not src.exists():
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
bin_name = f"{technique}_g{glibc_ver.replace('.', '')}{suffix}"
|
| 60 |
+
bin_path = BIN_DIR / bin_name
|
| 61 |
+
dump_path = DUMP_DIR / f"{bin_name}.jsonl"
|
| 62 |
+
|
| 63 |
+
# Compile (source from different glibc version, but runs on local glibc)
|
| 64 |
+
result = subprocess.run(
|
| 65 |
+
["gcc", "-o", str(bin_path), str(src),
|
| 66 |
+
"-std=c99", "-g", "-O0", "-Wno-all", "-lpthread"],
|
| 67 |
+
capture_output=True, text=True
|
| 68 |
+
)
|
| 69 |
+
if result.returncode != 0:
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
# Run with harness
|
| 73 |
+
env = os.environ.copy()
|
| 74 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 75 |
+
env["HEAPGRID_OUT"] = str(dump_path)
|
| 76 |
+
|
| 77 |
+
subprocess.run(
|
| 78 |
+
[str(bin_path)],
|
| 79 |
+
env=env, capture_output=True, timeout=10
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if dump_path.exists() and dump_path.stat().st_size > 0:
|
| 83 |
+
lines = sum(1 for _ in open(dump_path))
|
| 84 |
+
return lines
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def main():
|
| 89 |
+
DUMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
total_new = 0
|
| 91 |
+
|
| 92 |
+
for ver in GLIBC_VERSIONS:
|
| 93 |
+
if ver == "2.39":
|
| 94 |
+
continue # already have this from base run
|
| 95 |
+
print(f"\n--- glibc {ver} ---")
|
| 96 |
+
for tech in TECHNIQUES:
|
| 97 |
+
result = compile_and_run(tech, ver)
|
| 98 |
+
if result:
|
| 99 |
+
print(f" [OK] {tech} (glibc {ver}): {result} states")
|
| 100 |
+
total_new += result
|
| 101 |
+
else:
|
| 102 |
+
print(f" [--] {tech} (glibc {ver}): skipped")
|
| 103 |
+
|
| 104 |
+
print(f"\nTotal new states: {total_new}")
|
| 105 |
+
print("Re-run dataset_gen.py with all dump files to rebuild dataset.")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
main()
|
runner/gen_padded.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
gen_padded.py - Generate padded variants of short how2heap techniques.
|
| 4 |
+
|
| 5 |
+
For each technique with few states, creates C wrapper programs that:
|
| 6 |
+
1. Do N random noise allocations (varied N per variant)
|
| 7 |
+
2. Include the original technique code
|
| 8 |
+
3. Do more noise allocations after
|
| 9 |
+
|
| 10 |
+
This produces longer, varied sequences where the exploit is embedded.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import random
|
| 14 |
+
import subprocess
|
| 15 |
+
import os
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 19 |
+
HOW2HEAP = ROOT / "how2heap" / "glibc_2.39"
|
| 20 |
+
BIN_DIR = ROOT / "data" / "bins"
|
| 21 |
+
DUMP_DIR = ROOT / "data" / "dumps"
|
| 22 |
+
HARNESS = ROOT / "harness" / "heapgrid_harness.so"
|
| 23 |
+
|
| 24 |
+
# Techniques that need more samples
|
| 25 |
+
SHORT_TECHNIQUES = {
|
| 26 |
+
"tcache_house_of_spirit": 3,
|
| 27 |
+
"tcache_metadata_poisoning": 2,
|
| 28 |
+
"unsafe_unlink": 3,
|
| 29 |
+
"mmap_overlapping_chunks": 5,
|
| 30 |
+
"sysmalloc_int_free": 4,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def gen_noise_allocs(n, prefix="noise"):
|
| 35 |
+
"""Generate C code for N random noise allocations."""
|
| 36 |
+
lines = []
|
| 37 |
+
sizes = [0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80]
|
| 38 |
+
for i in range(n):
|
| 39 |
+
sz = random.choice(sizes)
|
| 40 |
+
lines.append(f" void *{prefix}_{i} = malloc({sz});")
|
| 41 |
+
# Free some randomly
|
| 42 |
+
indices = list(range(n))
|
| 43 |
+
random.shuffle(indices)
|
| 44 |
+
for i in indices[:n//3]:
|
| 45 |
+
lines.append(f" free({prefix}_{i});")
|
| 46 |
+
return "\n".join(lines)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def create_padded_variant(technique, variant_id, n_before, n_after):
|
| 50 |
+
"""Create a padded C file that wraps the technique with noise."""
|
| 51 |
+
src = HOW2HEAP / f"{technique}.c"
|
| 52 |
+
if not src.exists():
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
original = src.read_text()
|
| 56 |
+
|
| 57 |
+
# Extract the body of main (between first { and last })
|
| 58 |
+
# Simple heuristic: find 'int main' and extract body
|
| 59 |
+
lines = original.split('\n')
|
| 60 |
+
in_main = False
|
| 61 |
+
brace_depth = 0
|
| 62 |
+
main_body = []
|
| 63 |
+
includes = []
|
| 64 |
+
|
| 65 |
+
for line in lines:
|
| 66 |
+
if '#include' in line or '#define' in line:
|
| 67 |
+
includes.append(line)
|
| 68 |
+
if 'int main' in line or 'void main' in line:
|
| 69 |
+
in_main = True
|
| 70 |
+
continue
|
| 71 |
+
if in_main:
|
| 72 |
+
for ch in line:
|
| 73 |
+
if ch == '{':
|
| 74 |
+
brace_depth += 1
|
| 75 |
+
elif ch == '}':
|
| 76 |
+
brace_depth -= 1
|
| 77 |
+
if brace_depth > 0 or (brace_depth == 0 and '{' not in line and '}' not in line):
|
| 78 |
+
main_body.append(line)
|
| 79 |
+
|
| 80 |
+
# Build padded program
|
| 81 |
+
noise_before = gen_noise_allocs(n_before, "pre")
|
| 82 |
+
noise_after = gen_noise_allocs(n_after, "post")
|
| 83 |
+
|
| 84 |
+
padded = f"""
|
| 85 |
+
{chr(10).join(includes)}
|
| 86 |
+
#include <stdio.h>
|
| 87 |
+
#include <stdlib.h>
|
| 88 |
+
#include <string.h>
|
| 89 |
+
#include <stdint.h>
|
| 90 |
+
#include <assert.h>
|
| 91 |
+
|
| 92 |
+
int main() {{
|
| 93 |
+
setbuf(stdout, NULL);
|
| 94 |
+
setbuf(stderr, NULL);
|
| 95 |
+
|
| 96 |
+
// Noise allocations before exploit
|
| 97 |
+
{noise_before}
|
| 98 |
+
|
| 99 |
+
// Original technique
|
| 100 |
+
{chr(10).join(main_body)}
|
| 101 |
+
|
| 102 |
+
// Noise allocations after exploit
|
| 103 |
+
{noise_after}
|
| 104 |
+
|
| 105 |
+
return 0;
|
| 106 |
+
}}
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
# Write, compile, run
|
| 110 |
+
name = f"{technique}_pad{variant_id}"
|
| 111 |
+
src_path = BIN_DIR / f"{name}.c"
|
| 112 |
+
bin_path = BIN_DIR / name
|
| 113 |
+
dump_path = DUMP_DIR / f"{name}.jsonl"
|
| 114 |
+
|
| 115 |
+
src_path.write_text(padded)
|
| 116 |
+
|
| 117 |
+
r = subprocess.run(
|
| 118 |
+
["gcc", "-o", str(bin_path), str(src_path),
|
| 119 |
+
"-std=c99", "-g", "-O0", "-Wno-all", "-lpthread"],
|
| 120 |
+
capture_output=True, text=True
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if r.returncode != 0:
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
# Run with harness
|
| 127 |
+
env = os.environ.copy()
|
| 128 |
+
env["LD_PRELOAD"] = str(HARNESS)
|
| 129 |
+
env["HEAPGRID_OUT"] = str(dump_path)
|
| 130 |
+
|
| 131 |
+
subprocess.run([str(bin_path)], env=env, capture_output=True, timeout=10)
|
| 132 |
+
|
| 133 |
+
if dump_path.exists() and dump_path.stat().st_size > 0:
|
| 134 |
+
n_states = sum(1 for _ in open(dump_path))
|
| 135 |
+
return n_states
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def main():
|
| 140 |
+
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
DUMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
|
| 143 |
+
random.seed(42)
|
| 144 |
+
|
| 145 |
+
for technique, base_states in SHORT_TECHNIQUES.items():
|
| 146 |
+
print(f"\n=== {technique} (base: {base_states} states) ===")
|
| 147 |
+
total_new = 0
|
| 148 |
+
|
| 149 |
+
for v in range(20): # 20 variants per technique
|
| 150 |
+
n_before = random.randint(3, 15)
|
| 151 |
+
n_after = random.randint(2, 8)
|
| 152 |
+
n_states = create_padded_variant(technique, v, n_before, n_after)
|
| 153 |
+
|
| 154 |
+
if n_states:
|
| 155 |
+
print(f" variant {v}: {n_states} states (pad {n_before}+{n_after})")
|
| 156 |
+
total_new += n_states
|
| 157 |
+
else:
|
| 158 |
+
print(f" variant {v}: FAILED")
|
| 159 |
+
|
| 160 |
+
print(f" Total new states: {total_new}")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
main()
|
runner/run_poc.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_poc.py - End-to-end validation pipeline for HeapTRM.
|
| 4 |
+
|
| 5 |
+
1. Compiles the LD_PRELOAD harness
|
| 6 |
+
2. Compiles how2heap examples for the local glibc version
|
| 7 |
+
3. Runs each instrumented, collecting heap state dumps
|
| 8 |
+
4. Generates grid datasets
|
| 9 |
+
5. Trains HeapTRM
|
| 10 |
+
6. Evaluates on held-out techniques
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import subprocess
|
| 14 |
+
import sys
|
| 15 |
+
import os
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Project root
|
| 19 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 20 |
+
HOW2HEAP = ROOT / "how2heap"
|
| 21 |
+
HARNESS_DIR = ROOT / "harness"
|
| 22 |
+
DUMP_DIR = ROOT / "data" / "dumps"
|
| 23 |
+
PROCESSED_DIR = ROOT / "data" / "processed"
|
| 24 |
+
CHECKPOINT_DIR = ROOT / "data" / "checkpoints"
|
| 25 |
+
|
| 26 |
+
# Detect glibc version
|
| 27 |
+
GLIBC_VERSION = "2.39" # matches system
|
| 28 |
+
|
| 29 |
+
# Techniques to use
|
| 30 |
+
TRAIN_TECHNIQUES = [
|
| 31 |
+
"fastbin_dup",
|
| 32 |
+
"fastbin_dup_into_stack",
|
| 33 |
+
"tcache_poisoning",
|
| 34 |
+
"house_of_spirit",
|
| 35 |
+
"unsafe_unlink",
|
| 36 |
+
"tcache_house_of_spirit",
|
| 37 |
+
"tcache_stashing_unlink_attack",
|
| 38 |
+
"house_of_einherjar",
|
| 39 |
+
"large_bin_attack",
|
| 40 |
+
"poison_null_byte",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
TEST_TECHNIQUES = [
|
| 44 |
+
"fastbin_dup_consolidate",
|
| 45 |
+
"house_of_botcake",
|
| 46 |
+
"overlapping_chunks",
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
ALL_TECHNIQUES = TRAIN_TECHNIQUES + TEST_TECHNIQUES
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def run(cmd, **kwargs):
|
| 53 |
+
"""Run a command, print it, and check for errors."""
|
| 54 |
+
print(f" $ {' '.join(str(c) for c in cmd)}")
|
| 55 |
+
result = subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
| 56 |
+
if result.returncode != 0:
|
| 57 |
+
print(f" STDERR: {result.stderr[:500]}")
|
| 58 |
+
return result
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def step1_build_harness():
|
| 62 |
+
"""Compile the LD_PRELOAD heap instrumentation harness."""
|
| 63 |
+
print("\n=== Step 1: Building harness ===")
|
| 64 |
+
result = run(["make", "-C", str(HARNESS_DIR), "clean"])
|
| 65 |
+
result = run(["make", "-C", str(HARNESS_DIR)])
|
| 66 |
+
harness_so = HARNESS_DIR / "heapgrid_harness.so"
|
| 67 |
+
if not harness_so.exists():
|
| 68 |
+
print("FATAL: Failed to build harness")
|
| 69 |
+
sys.exit(1)
|
| 70 |
+
print(f" Built: {harness_so}")
|
| 71 |
+
return harness_so
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def step2_compile_examples():
|
| 75 |
+
"""Compile how2heap examples for our glibc version."""
|
| 76 |
+
print("\n=== Step 2: Compiling how2heap examples ===")
|
| 77 |
+
src_dir = HOW2HEAP / f"glibc_{GLIBC_VERSION}"
|
| 78 |
+
bin_dir = ROOT / "data" / "bins"
|
| 79 |
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
| 80 |
+
|
| 81 |
+
compiled = {}
|
| 82 |
+
for tech in ALL_TECHNIQUES:
|
| 83 |
+
src = src_dir / f"{tech}.c"
|
| 84 |
+
if not src.exists():
|
| 85 |
+
print(f" [SKIP] {src} not found")
|
| 86 |
+
continue
|
| 87 |
+
|
| 88 |
+
out = bin_dir / tech
|
| 89 |
+
result = run([
|
| 90 |
+
"gcc", "-o", str(out), str(src),
|
| 91 |
+
"-std=c99", "-g", "-O0",
|
| 92 |
+
"-Wno-all",
|
| 93 |
+
"-lpthread", # some examples need it
|
| 94 |
+
])
|
| 95 |
+
|
| 96 |
+
if result.returncode == 0 and out.exists():
|
| 97 |
+
compiled[tech] = out
|
| 98 |
+
print(f" [OK] {tech}")
|
| 99 |
+
else:
|
| 100 |
+
print(f" [FAIL] {tech}: {result.stderr[:200]}")
|
| 101 |
+
|
| 102 |
+
return compiled
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def step3_collect_dumps(compiled: dict, harness_so: Path):
|
| 106 |
+
"""Run each compiled example with the harness and collect dumps."""
|
| 107 |
+
print("\n=== Step 3: Collecting heap state dumps ===")
|
| 108 |
+
DUMP_DIR.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
|
| 110 |
+
collected = []
|
| 111 |
+
for tech, binary in compiled.items():
|
| 112 |
+
dump_file = DUMP_DIR / f"{tech}.jsonl"
|
| 113 |
+
|
| 114 |
+
env = os.environ.copy()
|
| 115 |
+
env["LD_PRELOAD"] = str(harness_so)
|
| 116 |
+
env["HEAPGRID_OUT"] = str(dump_file)
|
| 117 |
+
|
| 118 |
+
result = run([str(binary)], env=env, timeout=10)
|
| 119 |
+
|
| 120 |
+
if dump_file.exists() and dump_file.stat().st_size > 0:
|
| 121 |
+
n_lines = sum(1 for _ in open(dump_file))
|
| 122 |
+
print(f" [OK] {tech}: {n_lines} states captured")
|
| 123 |
+
collected.append(tech)
|
| 124 |
+
else:
|
| 125 |
+
print(f" [FAIL] {tech}: no dump output")
|
| 126 |
+
if result.stderr:
|
| 127 |
+
print(f" stderr: {result.stderr[:200]}")
|
| 128 |
+
|
| 129 |
+
return collected
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def step4_generate_dataset(collected: list):
|
| 133 |
+
"""Convert dumps to numpy grid arrays."""
|
| 134 |
+
print("\n=== Step 4: Generating grid dataset ===")
|
| 135 |
+
|
| 136 |
+
# Filter techniques to only those successfully collected
|
| 137 |
+
train = [t for t in TRAIN_TECHNIQUES if t in collected]
|
| 138 |
+
test = [t for t in TEST_TECHNIQUES if t in collected]
|
| 139 |
+
|
| 140 |
+
print(f" Train techniques: {train}")
|
| 141 |
+
print(f" Test techniques: {test}")
|
| 142 |
+
|
| 143 |
+
# Import dataset generator
|
| 144 |
+
sys.path.insert(0, str(ROOT / "dataset"))
|
| 145 |
+
from dataset_gen import build_dataset
|
| 146 |
+
|
| 147 |
+
build_dataset(DUMP_DIR, PROCESSED_DIR, train, test)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def step5_train_and_evaluate():
|
| 151 |
+
"""Train HeapTRM and evaluate."""
|
| 152 |
+
print("\n=== Step 5: Training HeapTRM ===")
|
| 153 |
+
|
| 154 |
+
sys.path.insert(0, str(ROOT / "model"))
|
| 155 |
+
from trm_heap import train_model
|
| 156 |
+
|
| 157 |
+
model, metrics = train_model(
|
| 158 |
+
data_dir=PROCESSED_DIR,
|
| 159 |
+
output_dir=CHECKPOINT_DIR,
|
| 160 |
+
hidden_dim=128,
|
| 161 |
+
n_outer=3,
|
| 162 |
+
n_inner=6,
|
| 163 |
+
epochs=100,
|
| 164 |
+
batch_size=16,
|
| 165 |
+
lr=3e-4,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
print("\n" + "=" * 60)
|
| 169 |
+
print("FINAL RESULTS")
|
| 170 |
+
print("=" * 60)
|
| 171 |
+
print(f" Accuracy: {metrics['accuracy']:.3f}")
|
| 172 |
+
print(f" Precision: {metrics['precision']:.3f}")
|
| 173 |
+
print(f" Recall: {metrics['recall']:.3f}")
|
| 174 |
+
print(f" F1: {metrics['f1']:.3f}")
|
| 175 |
+
print(f" TP={metrics['tp']} FP={metrics['fp']} "
|
| 176 |
+
f"FN={metrics['fn']} TN={metrics['tn']}")
|
| 177 |
+
print("=" * 60)
|
| 178 |
+
|
| 179 |
+
return metrics
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def main():
|
| 183 |
+
print("HeapTRM Proof-of-Concept Validation Pipeline")
|
| 184 |
+
print("=" * 60)
|
| 185 |
+
|
| 186 |
+
harness_so = step1_build_harness()
|
| 187 |
+
compiled = step2_compile_examples()
|
| 188 |
+
|
| 189 |
+
if not compiled:
|
| 190 |
+
print("\nFATAL: No examples compiled successfully")
|
| 191 |
+
sys.exit(1)
|
| 192 |
+
|
| 193 |
+
collected = step3_collect_dumps(compiled, harness_so)
|
| 194 |
+
|
| 195 |
+
if not collected:
|
| 196 |
+
print("\nFATAL: No dumps collected")
|
| 197 |
+
sys.exit(1)
|
| 198 |
+
|
| 199 |
+
step4_generate_dataset(collected)
|
| 200 |
+
metrics = step5_train_and_evaluate()
|
| 201 |
+
|
| 202 |
+
# Exit code based on whether model learned anything
|
| 203 |
+
if metrics['f1'] > 0.5:
|
| 204 |
+
print("\nVERDICT: TRM shows signal on heap state reasoning. Worth pursuing.")
|
| 205 |
+
sys.exit(0)
|
| 206 |
+
else:
|
| 207 |
+
print("\nVERDICT: TRM did not learn meaningful patterns. Reconsider approach.")
|
| 208 |
+
sys.exit(1)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__":
|
| 212 |
+
main()
|
simulator/heap_sim.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
heap_sim.py - Lightweight ptmalloc2 heap allocator simulator.
|
| 3 |
+
|
| 4 |
+
Simulates the core glibc heap allocator behavior:
|
| 5 |
+
- Chunk metadata (prev_size, size, flags, fd, bk)
|
| 6 |
+
- Tcache (per-size LIFO, 7 entries max, sizes 0x20-0x410)
|
| 7 |
+
- Fastbins (LIFO, sizes 0x20-0x80)
|
| 8 |
+
- Unsorted bin (doubly-linked)
|
| 9 |
+
- Forward/backward coalescing
|
| 10 |
+
- Top chunk splitting
|
| 11 |
+
- Request size -> chunk size conversion
|
| 12 |
+
|
| 13 |
+
Does NOT simulate:
|
| 14 |
+
- Safe linking (we want to model the exploitable state)
|
| 15 |
+
- mmap'd chunks
|
| 16 |
+
- Thread arenas
|
| 17 |
+
- Large bin sorting
|
| 18 |
+
|
| 19 |
+
This is deliberately simplified to be fast enough for self-play training.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from typing import Optional, List, Dict, Tuple
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
|
| 26 |
+
# Constants (64-bit)
|
| 27 |
+
SIZE_SZ = 8
|
| 28 |
+
MALLOC_ALIGN_MASK = 0xF
|
| 29 |
+
MINSIZE = 0x20
|
| 30 |
+
TCACHE_MAX_BINS = 64
|
| 31 |
+
TCACHE_FILL_COUNT = 7
|
| 32 |
+
MAX_FASTBIN_SIZE = 0x80
|
| 33 |
+
NUM_FASTBINS = 7 # sizes 0x20, 0x30, ..., 0x80
|
| 34 |
+
INITIAL_HEAP_BASE = 0x555555559000
|
| 35 |
+
TOP_CHUNK_INITIAL_SIZE = 0x21000
|
| 36 |
+
|
| 37 |
+
# Flags
|
| 38 |
+
PREV_INUSE = 0x1
|
| 39 |
+
IS_MMAPPED = 0x2
|
| 40 |
+
NON_MAIN_ARENA = 0x4
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def request_to_chunk_size(req: int) -> int:
|
| 44 |
+
"""Convert malloc request size to actual chunk size."""
|
| 45 |
+
if req + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE:
|
| 46 |
+
return MINSIZE
|
| 47 |
+
return (req + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def tcache_idx(chunk_size: int) -> int:
|
| 51 |
+
"""Tcache bin index for a chunk size."""
|
| 52 |
+
return (chunk_size - MINSIZE) // 0x10
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fastbin_idx(chunk_size: int) -> int:
|
| 56 |
+
"""Fastbin index for a chunk size."""
|
| 57 |
+
return (chunk_size - MINSIZE) // 0x10
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class Chunk:
|
| 62 |
+
"""Represents a heap chunk."""
|
| 63 |
+
addr: int # address of chunk start (before user data)
|
| 64 |
+
prev_size: int = 0
|
| 65 |
+
size: int = 0 # includes flags in low 3 bits
|
| 66 |
+
allocated: bool = True
|
| 67 |
+
fd: int = 0 # forward pointer (freed chunks)
|
| 68 |
+
bk: int = 0 # backward pointer (freed chunks)
|
| 69 |
+
user_data: bytes = b'' # first N bytes of user data
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def real_size(self) -> int:
|
| 73 |
+
return self.size & ~0x7
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def prev_inuse(self) -> bool:
|
| 77 |
+
return bool(self.size & PREV_INUSE)
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def user_addr(self) -> int:
|
| 81 |
+
return self.addr + 2 * SIZE_SZ # skip prev_size + size
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def next_chunk_addr(self) -> int:
|
| 85 |
+
return self.addr + self.real_size
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class HeapSimulator:
|
| 89 |
+
"""Simulates the glibc ptmalloc2 heap allocator."""
|
| 90 |
+
|
| 91 |
+
def __init__(self, heap_base: int = INITIAL_HEAP_BASE):
|
| 92 |
+
self.heap_base = heap_base
|
| 93 |
+
self.chunks: Dict[int, Chunk] = {} # addr -> Chunk
|
| 94 |
+
|
| 95 |
+
# Tcache: list of chunk addrs per size class
|
| 96 |
+
self.tcache: List[List[int]] = [[] for _ in range(TCACHE_MAX_BINS)]
|
| 97 |
+
|
| 98 |
+
# Fastbins: list of chunk addrs per size class
|
| 99 |
+
self.fastbins: List[List[int]] = [[] for _ in range(NUM_FASTBINS)]
|
| 100 |
+
|
| 101 |
+
# Unsorted bin: list of chunk addrs
|
| 102 |
+
self.unsorted_bin: List[int] = []
|
| 103 |
+
|
| 104 |
+
# Top chunk
|
| 105 |
+
self.top_addr = heap_base
|
| 106 |
+
self.top_size = TOP_CHUNK_INITIAL_SIZE
|
| 107 |
+
|
| 108 |
+
# Tracking
|
| 109 |
+
self.alloc_count = 0
|
| 110 |
+
self.free_count = 0
|
| 111 |
+
self.step = 0
|
| 112 |
+
self.history: List[dict] = []
|
| 113 |
+
|
| 114 |
+
# User-facing slots (like CTF menu: slots 0-15)
|
| 115 |
+
self.slots: Dict[int, int] = {} # slot_idx -> user_addr
|
| 116 |
+
|
| 117 |
+
def _record(self, op: str, **kwargs):
|
| 118 |
+
self.history.append({"step": self.step, "op": op, **kwargs})
|
| 119 |
+
self.step += 1
|
| 120 |
+
|
| 121 |
+
# ============================================================
|
| 122 |
+
# MALLOC
|
| 123 |
+
# ============================================================
|
| 124 |
+
|
| 125 |
+
def malloc(self, req_size: int, slot: Optional[int] = None) -> Optional[int]:
|
| 126 |
+
"""Allocate a chunk. Returns user pointer or None."""
|
| 127 |
+
chunk_size = request_to_chunk_size(req_size)
|
| 128 |
+
|
| 129 |
+
user_addr = None
|
| 130 |
+
|
| 131 |
+
# 1. Try tcache
|
| 132 |
+
if chunk_size <= 0x410:
|
| 133 |
+
idx = tcache_idx(chunk_size)
|
| 134 |
+
if idx < TCACHE_MAX_BINS and self.tcache[idx]:
|
| 135 |
+
addr = self.tcache[idx].pop()
|
| 136 |
+
chunk = self.chunks[addr]
|
| 137 |
+
chunk.allocated = True
|
| 138 |
+
chunk.fd = 0
|
| 139 |
+
chunk.bk = 0
|
| 140 |
+
user_addr = chunk.user_addr
|
| 141 |
+
self._set_next_prev_inuse(chunk, True)
|
| 142 |
+
|
| 143 |
+
# 2. Try fastbin
|
| 144 |
+
if user_addr is None and chunk_size <= MAX_FASTBIN_SIZE:
|
| 145 |
+
idx = fastbin_idx(chunk_size)
|
| 146 |
+
if idx < NUM_FASTBINS and self.fastbins[idx]:
|
| 147 |
+
addr = self.fastbins[idx].pop()
|
| 148 |
+
chunk = self.chunks[addr]
|
| 149 |
+
chunk.allocated = True
|
| 150 |
+
chunk.fd = 0
|
| 151 |
+
chunk.bk = 0
|
| 152 |
+
user_addr = chunk.user_addr
|
| 153 |
+
self._set_next_prev_inuse(chunk, True)
|
| 154 |
+
|
| 155 |
+
# 3. Try unsorted bin (first fit)
|
| 156 |
+
if user_addr is None:
|
| 157 |
+
for i, addr in enumerate(self.unsorted_bin):
|
| 158 |
+
chunk = self.chunks[addr]
|
| 159 |
+
if chunk.real_size >= chunk_size:
|
| 160 |
+
self.unsorted_bin.pop(i)
|
| 161 |
+
remainder = chunk.real_size - chunk_size
|
| 162 |
+
if remainder >= MINSIZE:
|
| 163 |
+
# Split
|
| 164 |
+
self._split_chunk(chunk, chunk_size)
|
| 165 |
+
chunk.allocated = True
|
| 166 |
+
chunk.size = chunk_size | PREV_INUSE
|
| 167 |
+
chunk.fd = 0
|
| 168 |
+
chunk.bk = 0
|
| 169 |
+
user_addr = chunk.user_addr
|
| 170 |
+
self._set_next_prev_inuse(chunk, True)
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
# 4. Split top chunk
|
| 174 |
+
if user_addr is None:
|
| 175 |
+
if self.top_size >= chunk_size + MINSIZE:
|
| 176 |
+
addr = self.top_addr
|
| 177 |
+
chunk = Chunk(
|
| 178 |
+
addr=addr,
|
| 179 |
+
size=chunk_size | PREV_INUSE,
|
| 180 |
+
allocated=True,
|
| 181 |
+
)
|
| 182 |
+
self.chunks[addr] = chunk
|
| 183 |
+
self.top_addr = addr + chunk_size
|
| 184 |
+
self.top_size -= chunk_size
|
| 185 |
+
user_addr = chunk.user_addr
|
| 186 |
+
else:
|
| 187 |
+
return None # OOM
|
| 188 |
+
|
| 189 |
+
if slot is not None and user_addr is not None:
|
| 190 |
+
self.slots[slot] = user_addr
|
| 191 |
+
|
| 192 |
+
self.alloc_count += 1
|
| 193 |
+
self._record("malloc", size=req_size, chunk_size=chunk_size,
|
| 194 |
+
user_addr=user_addr, slot=slot)
|
| 195 |
+
return user_addr
|
| 196 |
+
|
| 197 |
+
# ============================================================
|
| 198 |
+
# FREE
|
| 199 |
+
# ============================================================
|
| 200 |
+
|
| 201 |
+
def free(self, user_addr: int = 0, slot: Optional[int] = None) -> bool:
|
| 202 |
+
"""Free a chunk by user pointer or slot. Returns success."""
|
| 203 |
+
if slot is not None:
|
| 204 |
+
user_addr = self.slots.get(slot, 0)
|
| 205 |
+
if not user_addr:
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
chunk_addr = user_addr - 2 * SIZE_SZ
|
| 209 |
+
chunk = self.chunks.get(chunk_addr)
|
| 210 |
+
if chunk is None:
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
chunk_size = chunk.real_size
|
| 214 |
+
|
| 215 |
+
# 1. Try tcache
|
| 216 |
+
if chunk_size <= 0x410:
|
| 217 |
+
idx = tcache_idx(chunk_size)
|
| 218 |
+
if idx < TCACHE_MAX_BINS and len(self.tcache[idx]) < TCACHE_FILL_COUNT:
|
| 219 |
+
chunk.allocated = False
|
| 220 |
+
chunk.fd = self.tcache[idx][-1] if self.tcache[idx] else 0
|
| 221 |
+
chunk.bk = 0
|
| 222 |
+
self.tcache[idx].append(chunk_addr)
|
| 223 |
+
self.free_count += 1
|
| 224 |
+
self._record("free", user_addr=user_addr, bin="tcache",
|
| 225 |
+
chunk_size=chunk_size, slot=slot)
|
| 226 |
+
return True
|
| 227 |
+
|
| 228 |
+
# 2. Fastbin
|
| 229 |
+
if chunk_size <= MAX_FASTBIN_SIZE:
|
| 230 |
+
idx = fastbin_idx(chunk_size)
|
| 231 |
+
if idx < NUM_FASTBINS:
|
| 232 |
+
chunk.allocated = False
|
| 233 |
+
chunk.fd = self.fastbins[idx][-1] if self.fastbins[idx] else 0
|
| 234 |
+
self.fastbins[idx].append(chunk_addr)
|
| 235 |
+
self.free_count += 1
|
| 236 |
+
self._record("free", user_addr=user_addr, bin="fastbin",
|
| 237 |
+
chunk_size=chunk_size, slot=slot)
|
| 238 |
+
return True
|
| 239 |
+
|
| 240 |
+
# 3. Coalesce and put in unsorted bin
|
| 241 |
+
chunk.allocated = False
|
| 242 |
+
self._coalesce_and_unsort(chunk)
|
| 243 |
+
self.free_count += 1
|
| 244 |
+
self._record("free", user_addr=user_addr, bin="unsorted",
|
| 245 |
+
chunk_size=chunk_size, slot=slot)
|
| 246 |
+
return True
|
| 247 |
+
|
| 248 |
+
# ============================================================
|
| 249 |
+
# WRITE (simulate vulnerability)
|
| 250 |
+
# ============================================================
|
| 251 |
+
|
| 252 |
+
def write(self, user_addr: int, data: bytes, overflow: int = 0) -> bool:
|
| 253 |
+
"""Write data to a chunk. overflow > 0 allows OOB write."""
|
| 254 |
+
chunk_addr = user_addr - 2 * SIZE_SZ
|
| 255 |
+
chunk = self.chunks.get(chunk_addr)
|
| 256 |
+
if chunk is None:
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
chunk.user_data = data
|
| 260 |
+
|
| 261 |
+
# Simulate overflow: corrupt next chunk's metadata
|
| 262 |
+
if overflow > 0:
|
| 263 |
+
next_addr = chunk.next_chunk_addr
|
| 264 |
+
next_chunk = self.chunks.get(next_addr)
|
| 265 |
+
if next_chunk and overflow >= 1:
|
| 266 |
+
# Off-by-one null byte: corrupt size field's LSB
|
| 267 |
+
next_chunk.size = next_chunk.size & ~0xFF
|
| 268 |
+
# More overflow: can corrupt fd/bk
|
| 269 |
+
if overflow >= SIZE_SZ and not next_chunk.allocated:
|
| 270 |
+
# Overwrite fd pointer
|
| 271 |
+
if len(data) > chunk.real_size - 2 * SIZE_SZ:
|
| 272 |
+
overflow_data = data[chunk.real_size - 2 * SIZE_SZ:]
|
| 273 |
+
if len(overflow_data) >= 8:
|
| 274 |
+
next_chunk.fd = int.from_bytes(
|
| 275 |
+
overflow_data[:8], 'little')
|
| 276 |
+
|
| 277 |
+
self._record("write", user_addr=user_addr,
|
| 278 |
+
data_len=len(data), overflow=overflow)
|
| 279 |
+
return True
|
| 280 |
+
|
| 281 |
+
def write_to_freed(self, user_addr: int, fd_value: int) -> bool:
|
| 282 |
+
"""UAF: write fd pointer of a freed chunk."""
|
| 283 |
+
chunk_addr = user_addr - 2 * SIZE_SZ
|
| 284 |
+
chunk = self.chunks.get(chunk_addr)
|
| 285 |
+
if chunk is None or chunk.allocated:
|
| 286 |
+
return False
|
| 287 |
+
|
| 288 |
+
chunk.fd = fd_value
|
| 289 |
+
self._record("write_freed", user_addr=user_addr, fd_value=fd_value)
|
| 290 |
+
return True
|
| 291 |
+
|
| 292 |
+
# ============================================================
|
| 293 |
+
# INTERNAL HELPERS
|
| 294 |
+
# ============================================================
|
| 295 |
+
|
| 296 |
+
def _set_next_prev_inuse(self, chunk: Chunk, inuse: bool):
|
| 297 |
+
"""Set the PREV_INUSE bit of the chunk following `chunk`."""
|
| 298 |
+
next_addr = chunk.next_chunk_addr
|
| 299 |
+
next_chunk = self.chunks.get(next_addr)
|
| 300 |
+
if next_chunk:
|
| 301 |
+
if inuse:
|
| 302 |
+
next_chunk.size |= PREV_INUSE
|
| 303 |
+
else:
|
| 304 |
+
next_chunk.size &= ~PREV_INUSE
|
| 305 |
+
next_chunk.prev_size = chunk.real_size
|
| 306 |
+
|
| 307 |
+
def _split_chunk(self, chunk: Chunk, new_size: int):
|
| 308 |
+
"""Split chunk into (new_size) and remainder."""
|
| 309 |
+
remainder_addr = chunk.addr + new_size
|
| 310 |
+
remainder_size = chunk.real_size - new_size
|
| 311 |
+
remainder = Chunk(
|
| 312 |
+
addr=remainder_addr,
|
| 313 |
+
prev_size=new_size,
|
| 314 |
+
size=remainder_size | PREV_INUSE,
|
| 315 |
+
allocated=False,
|
| 316 |
+
)
|
| 317 |
+
self.chunks[remainder_addr] = remainder
|
| 318 |
+
self.unsorted_bin.append(remainder_addr)
|
| 319 |
+
|
| 320 |
+
def _coalesce_and_unsort(self, chunk: Chunk):
|
| 321 |
+
"""Coalesce with neighbors and put in unsorted bin."""
|
| 322 |
+
addr = chunk.addr
|
| 323 |
+
size = chunk.real_size
|
| 324 |
+
|
| 325 |
+
# Forward coalesce: merge with next chunk if free
|
| 326 |
+
next_addr = addr + size
|
| 327 |
+
next_chunk = self.chunks.get(next_addr)
|
| 328 |
+
if next_chunk and not next_chunk.allocated:
|
| 329 |
+
# Remove next from whatever bin it's in
|
| 330 |
+
self._remove_from_bins(next_addr)
|
| 331 |
+
size += next_chunk.real_size
|
| 332 |
+
del self.chunks[next_addr]
|
| 333 |
+
|
| 334 |
+
# Backward coalesce: merge with prev chunk if free
|
| 335 |
+
if not chunk.prev_inuse and chunk.prev_size > 0:
|
| 336 |
+
prev_addr = addr - chunk.prev_size
|
| 337 |
+
prev_chunk = self.chunks.get(prev_addr)
|
| 338 |
+
if prev_chunk and not prev_chunk.allocated:
|
| 339 |
+
self._remove_from_bins(prev_addr)
|
| 340 |
+
size += prev_chunk.real_size
|
| 341 |
+
del self.chunks[addr]
|
| 342 |
+
addr = prev_addr
|
| 343 |
+
chunk = prev_chunk
|
| 344 |
+
|
| 345 |
+
# Update chunk
|
| 346 |
+
chunk.addr = addr
|
| 347 |
+
chunk.size = size | PREV_INUSE # prev of coalesced is always inuse
|
| 348 |
+
chunk.allocated = False
|
| 349 |
+
self.chunks[addr] = chunk
|
| 350 |
+
|
| 351 |
+
# Check if coalescing into top
|
| 352 |
+
if addr + size == self.top_addr:
|
| 353 |
+
self.top_addr = addr
|
| 354 |
+
self.top_size += size
|
| 355 |
+
if addr in self.chunks:
|
| 356 |
+
del self.chunks[addr]
|
| 357 |
+
return
|
| 358 |
+
|
| 359 |
+
self.unsorted_bin.append(addr)
|
| 360 |
+
self._set_next_prev_inuse(chunk, False)
|
| 361 |
+
|
| 362 |
+
def _remove_from_bins(self, addr: int):
|
| 363 |
+
"""Remove a chunk addr from whichever bin it's in."""
|
| 364 |
+
for tc in self.tcache:
|
| 365 |
+
if addr in tc:
|
| 366 |
+
tc.remove(addr)
|
| 367 |
+
return
|
| 368 |
+
for fb in self.fastbins:
|
| 369 |
+
if addr in fb:
|
| 370 |
+
fb.remove(addr)
|
| 371 |
+
return
|
| 372 |
+
if addr in self.unsorted_bin:
|
| 373 |
+
self.unsorted_bin.remove(addr)
|
| 374 |
+
|
| 375 |
+
# ============================================================
|
| 376 |
+
# STATE OBSERVATION
|
| 377 |
+
# ============================================================
|
| 378 |
+
|
| 379 |
+
def get_state(self) -> dict:
|
| 380 |
+
"""Return current heap state as a dict (for grid encoding)."""
|
| 381 |
+
chunks_info = []
|
| 382 |
+
for addr in sorted(self.chunks.keys()):
|
| 383 |
+
c = self.chunks[addr]
|
| 384 |
+
# Determine which bin it's in
|
| 385 |
+
bin_type = "none"
|
| 386 |
+
if not c.allocated:
|
| 387 |
+
for i, tc in enumerate(self.tcache):
|
| 388 |
+
if addr in tc:
|
| 389 |
+
bin_type = f"tcache_{i}"
|
| 390 |
+
break
|
| 391 |
+
else:
|
| 392 |
+
for i, fb in enumerate(self.fastbins):
|
| 393 |
+
if addr in fb:
|
| 394 |
+
bin_type = f"fastbin_{i}"
|
| 395 |
+
break
|
| 396 |
+
else:
|
| 397 |
+
if addr in self.unsorted_bin:
|
| 398 |
+
bin_type = "unsorted"
|
| 399 |
+
|
| 400 |
+
# Which slot points here?
|
| 401 |
+
slot = None
|
| 402 |
+
for s, ua in self.slots.items():
|
| 403 |
+
if ua == c.user_addr:
|
| 404 |
+
slot = s
|
| 405 |
+
break
|
| 406 |
+
|
| 407 |
+
chunks_info.append({
|
| 408 |
+
"addr": addr,
|
| 409 |
+
"size": c.real_size,
|
| 410 |
+
"allocated": c.allocated,
|
| 411 |
+
"prev_inuse": c.prev_inuse,
|
| 412 |
+
"fd": c.fd,
|
| 413 |
+
"bk": c.bk,
|
| 414 |
+
"bin": bin_type,
|
| 415 |
+
"slot": slot,
|
| 416 |
+
"user_data_len": len(c.user_data),
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
return {
|
| 420 |
+
"step": self.step,
|
| 421 |
+
"n_chunks": len(self.chunks),
|
| 422 |
+
"chunks": chunks_info,
|
| 423 |
+
"tcache_counts": [len(tc) for tc in self.tcache],
|
| 424 |
+
"fastbin_counts": [len(fb) for fb in self.fastbins],
|
| 425 |
+
"unsorted_count": len(self.unsorted_bin),
|
| 426 |
+
"top_addr": self.top_addr,
|
| 427 |
+
"top_size": self.top_size,
|
| 428 |
+
"alloc_count": self.alloc_count,
|
| 429 |
+
"free_count": self.free_count,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
# ============================================================
|
| 433 |
+
# EXPLOIT PRIMITIVE DETECTION
|
| 434 |
+
# ============================================================
|
| 435 |
+
|
| 436 |
+
def check_primitives(self) -> dict:
|
| 437 |
+
"""Check for achieved exploit primitives."""
|
| 438 |
+
primitives = {
|
| 439 |
+
"overlapping_chunks": False,
|
| 440 |
+
"arbitrary_alloc": False,
|
| 441 |
+
"double_free": False,
|
| 442 |
+
"tcache_poison": False,
|
| 443 |
+
"freelist_cycle": False,
|
| 444 |
+
"duplicate_alloc": False,
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
# Check overlapping chunks: two allocated chunks whose regions overlap
|
| 448 |
+
alloc_chunks = [(a, c) for a, c in self.chunks.items() if c.allocated]
|
| 449 |
+
for i, (a1, c1) in enumerate(alloc_chunks):
|
| 450 |
+
for a2, c2 in alloc_chunks[i+1:]:
|
| 451 |
+
end1 = a1 + c1.real_size
|
| 452 |
+
end2 = a2 + c2.real_size
|
| 453 |
+
if a1 < end2 and a2 < end1:
|
| 454 |
+
primitives["overlapping_chunks"] = True
|
| 455 |
+
|
| 456 |
+
# Check tcache/fastbin for cycles (double free)
|
| 457 |
+
for bins in [self.tcache, self.fastbins]:
|
| 458 |
+
for bin_list in bins:
|
| 459 |
+
if len(bin_list) != len(set(bin_list)):
|
| 460 |
+
primitives["double_free"] = True
|
| 461 |
+
primitives["freelist_cycle"] = True
|
| 462 |
+
|
| 463 |
+
# Check for tcache poison: fd points outside heap OR fd was manually set
|
| 464 |
+
for tc in self.tcache:
|
| 465 |
+
for addr in tc:
|
| 466 |
+
chunk = self.chunks.get(addr)
|
| 467 |
+
if chunk and chunk.fd != 0:
|
| 468 |
+
if chunk.fd < self.heap_base or chunk.fd > self.top_addr + self.top_size:
|
| 469 |
+
primitives["tcache_poison"] = True
|
| 470 |
+
primitives["arbitrary_alloc"] = True
|
| 471 |
+
# Also check if fd points to an allocated chunk (shouldn't be in freelist)
|
| 472 |
+
fd_chunk = self.chunks.get(chunk.fd - 2 * SIZE_SZ)
|
| 473 |
+
if fd_chunk is None:
|
| 474 |
+
# fd points to user_addr of a chunk?
|
| 475 |
+
for a, c in self.chunks.items():
|
| 476 |
+
if c.user_addr == chunk.fd and c.allocated:
|
| 477 |
+
primitives["tcache_poison"] = True
|
| 478 |
+
break
|
| 479 |
+
|
| 480 |
+
# Check for duplicate allocation: two slots point to same user address
|
| 481 |
+
addrs = list(self.slots.values())
|
| 482 |
+
if len(addrs) != len(set(addrs)):
|
| 483 |
+
primitives["duplicate_alloc"] = True
|
| 484 |
+
primitives["arbitrary_alloc"] = True
|
| 485 |
+
|
| 486 |
+
return primitives
|
| 487 |
+
|
| 488 |
+
def state_to_grid(self) -> np.ndarray:
|
| 489 |
+
"""Convert current state to 32x16 grid for TRM input."""
|
| 490 |
+
grid = np.zeros((32, 16), dtype=np.int64)
|
| 491 |
+
|
| 492 |
+
state = self.get_state()
|
| 493 |
+
chunks = state["chunks"]
|
| 494 |
+
|
| 495 |
+
for i, c in enumerate(chunks[:32]):
|
| 496 |
+
# Col 0: state (1=alloc, 2=freed)
|
| 497 |
+
grid[i, 0] = 1 if c["allocated"] else 2
|
| 498 |
+
|
| 499 |
+
# Col 1: size class
|
| 500 |
+
grid[i, 1] = min(63, c["size"] >> 4)
|
| 501 |
+
|
| 502 |
+
# Col 2: prev_inuse
|
| 503 |
+
grid[i, 2] = 1 if c["prev_inuse"] else 0
|
| 504 |
+
|
| 505 |
+
# Col 3-4: unused flags
|
| 506 |
+
grid[i, 3] = 0
|
| 507 |
+
grid[i, 4] = 0
|
| 508 |
+
|
| 509 |
+
# Col 5: fd target (resolve to chunk index)
|
| 510 |
+
if c["fd"] != 0:
|
| 511 |
+
fd_idx = 33 # external
|
| 512 |
+
for j, c2 in enumerate(chunks[:32]):
|
| 513 |
+
if c2["addr"] == c["fd"]:
|
| 514 |
+
fd_idx = min(32, j + 1)
|
| 515 |
+
break
|
| 516 |
+
grid[i, 5] = fd_idx
|
| 517 |
+
else:
|
| 518 |
+
grid[i, 5] = 0
|
| 519 |
+
|
| 520 |
+
# Col 6: bk target
|
| 521 |
+
if c["bk"] != 0:
|
| 522 |
+
bk_idx = 33
|
| 523 |
+
for j, c2 in enumerate(chunks[:32]):
|
| 524 |
+
if c2["addr"] == c["bk"]:
|
| 525 |
+
bk_idx = min(32, j + 1)
|
| 526 |
+
break
|
| 527 |
+
grid[i, 6] = bk_idx
|
| 528 |
+
else:
|
| 529 |
+
grid[i, 6] = 0
|
| 530 |
+
|
| 531 |
+
# Col 7: bin type encoding
|
| 532 |
+
bin_str = c["bin"]
|
| 533 |
+
if "tcache" in bin_str:
|
| 534 |
+
grid[i, 7] = 1
|
| 535 |
+
elif "fastbin" in bin_str:
|
| 536 |
+
grid[i, 7] = 2
|
| 537 |
+
elif "unsorted" in bin_str:
|
| 538 |
+
grid[i, 7] = 3
|
| 539 |
+
else:
|
| 540 |
+
grid[i, 7] = 0
|
| 541 |
+
|
| 542 |
+
# Col 8: slot
|
| 543 |
+
grid[i, 8] = min(63, (c["slot"] or 0) + 1) if c["slot"] is not None else 0
|
| 544 |
+
|
| 545 |
+
# Col 9: tcache count for this size
|
| 546 |
+
sz = c["size"]
|
| 547 |
+
if sz <= 0x410:
|
| 548 |
+
idx = (sz - MINSIZE) // 0x10
|
| 549 |
+
if idx < TCACHE_MAX_BINS:
|
| 550 |
+
grid[i, 9] = min(63, state["tcache_counts"][idx])
|
| 551 |
+
|
| 552 |
+
# Col 10: has data
|
| 553 |
+
grid[i, 10] = min(63, c["user_data_len"])
|
| 554 |
+
|
| 555 |
+
# Col 11: chunk index
|
| 556 |
+
grid[i, 11] = min(63, i)
|
| 557 |
+
|
| 558 |
+
# Col 12: alloc_count
|
| 559 |
+
grid[i, 12] = min(63, state["alloc_count"])
|
| 560 |
+
|
| 561 |
+
# Col 13: free_count
|
| 562 |
+
grid[i, 13] = min(63, state["free_count"])
|
| 563 |
+
|
| 564 |
+
# Col 14: step
|
| 565 |
+
grid[i, 14] = min(63, state["step"])
|
| 566 |
+
|
| 567 |
+
# Col 15: size raw (for more granularity)
|
| 568 |
+
grid[i, 15] = min(63, c["size"] >> 3)
|
| 569 |
+
|
| 570 |
+
return grid
|
viz/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- security
|
| 5 |
+
- heap-exploitation
|
| 6 |
+
- tiny-recursive-model
|
| 7 |
+
- ctf
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Heap TRM
|
| 11 |
+
|
| 12 |
+
Tiny Recursive Model ([arXiv 2510.04871](https://arxiv.org/abs/2510.04871)) applied to glibc heap exploitation reasoning. ~3-10K parameter network that predicts exploit sequences from heap state grids.
|
| 13 |
+
|
| 14 |
+
## Quickstart
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
# Train
|
| 18 |
+
python3 runner/run_poc.py
|
| 19 |
+
|
| 20 |
+
# Visualize exploit trajectories
|
| 21 |
+
python3 viz/generate_trajectory.py --all
|
| 22 |
+
cd viz && python3 -m http.server 8888
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## Checkpoints
|
| 26 |
+
|
| 27 |
+
- `data/checkpoints/best_model.pt` — base
|
| 28 |
+
- `data/checkpoints_aug/best_model.pt` — augmented
|
| 29 |
+
- `data/checkpoints_focal/best_model.pt` — focal loss
|
viz/generate_trajectory.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate trajectory JSON for the heap-trm web visualizer.
|
| 3 |
+
|
| 4 |
+
Runs the HeapSimulator through a tcache-poisoning exploit sequence,
|
| 5 |
+
captures full state + grid + primitives at each step, and optionally
|
| 6 |
+
runs the trained model to get per-step predictions.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python viz/generate_trajectory.py [--model PATH] [-o OUTPUT]
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import sys
|
| 15 |
+
import os
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 19 |
+
|
| 20 |
+
from simulator.heap_sim import HeapSimulator
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Exploit sequences to demonstrate
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
SCENARIOS = {
|
| 27 |
+
"tcache_poison": {
|
| 28 |
+
"title": "Tcache Poisoning",
|
| 29 |
+
"description": (
|
| 30 |
+
"Allocate chunks, free them into the tcache, then corrupt a freed "
|
| 31 |
+
"chunk's forward pointer via use-after-free to redirect allocation."
|
| 32 |
+
),
|
| 33 |
+
"ops": [
|
| 34 |
+
{"type": "malloc", "size": 0x40, "slot": 0, "desc": "Allocate chunk A (0x40) in slot 0"},
|
| 35 |
+
{"type": "malloc", "size": 0x40, "slot": 1, "desc": "Allocate chunk B (0x40) in slot 1"},
|
| 36 |
+
{"type": "malloc", "size": 0x40, "slot": 2, "desc": "Allocate guard chunk C (0x40) in slot 2 — prevents consolidation"},
|
| 37 |
+
{"type": "free", "slot": 0, "desc": "Free chunk A → enters tcache[0x50]"},
|
| 38 |
+
{"type": "free", "slot": 1, "desc": "Free chunk B → enters tcache[0x50], fd → A"},
|
| 39 |
+
{"type": "write_freed", "slot": 1, "desc": "UAF write: corrupt B's fd pointer → attacker-controlled address"},
|
| 40 |
+
{"type": "malloc", "size": 0x40, "slot": 3, "desc": "Malloc from tcache → returns chunk B (draining)"},
|
| 41 |
+
{"type": "malloc", "size": 0x40, "slot": 4, "desc": "Malloc from tcache → returns POISONED address!"},
|
| 42 |
+
],
|
| 43 |
+
},
|
| 44 |
+
"fastbin_dup": {
|
| 45 |
+
"title": "Fastbin Duplication",
|
| 46 |
+
"description": (
|
| 47 |
+
"Exploit double-free in the fastbin to get two allocations "
|
| 48 |
+
"pointing to the same memory, enabling overlapping writes."
|
| 49 |
+
),
|
| 50 |
+
"ops": [
|
| 51 |
+
{"type": "malloc", "size": 0x20, "slot": 0, "desc": "Allocate chunk A (0x20) in slot 0"},
|
| 52 |
+
{"type": "malloc", "size": 0x20, "slot": 1, "desc": "Allocate chunk B (0x20) in slot 1"},
|
| 53 |
+
{"type": "malloc", "size": 0x20, "slot": 2, "desc": "Allocate guard chunk C (0x20) in slot 2"},
|
| 54 |
+
# Fill tcache for 0x30 so frees go to fastbin
|
| 55 |
+
{"type": "malloc", "size": 0x20, "slot": 5, "desc": "Allocate padding slot 5 (fill tcache later)"},
|
| 56 |
+
{"type": "malloc", "size": 0x20, "slot": 6, "desc": "Allocate padding slot 6"},
|
| 57 |
+
{"type": "malloc", "size": 0x20, "slot": 7, "desc": "Allocate padding slot 7"},
|
| 58 |
+
{"type": "malloc", "size": 0x20, "slot": 8, "desc": "Allocate padding slot 8"},
|
| 59 |
+
{"type": "malloc", "size": 0x20, "slot": 9, "desc": "Allocate padding slot 9"},
|
| 60 |
+
{"type": "malloc", "size": 0x20, "slot": 10, "desc": "Allocate padding slot 10"},
|
| 61 |
+
{"type": "malloc", "size": 0x20, "slot": 11, "desc": "Allocate padding slot 11"},
|
| 62 |
+
{"type": "malloc", "size": 0x20, "slot": 12, "desc": "Allocate guard for padding"},
|
| 63 |
+
{"type": "free", "slot": 5, "desc": "Free padding → tcache[0x30] (1/7)"},
|
| 64 |
+
{"type": "free", "slot": 6, "desc": "Free padding → tcache[0x30] (2/7)"},
|
| 65 |
+
{"type": "free", "slot": 7, "desc": "Free padding → tcache[0x30] (3/7)"},
|
| 66 |
+
{"type": "free", "slot": 8, "desc": "Free padding → tcache[0x30] (4/7)"},
|
| 67 |
+
{"type": "free", "slot": 9, "desc": "Free padding → tcache[0x30] (5/7)"},
|
| 68 |
+
{"type": "free", "slot": 10, "desc": "Free padding → tcache[0x30] (6/7)"},
|
| 69 |
+
{"type": "free", "slot": 11, "desc": "Free padding → tcache[0x30] (7/7)"},
|
| 70 |
+
{"type": "free", "slot": 0, "desc": "Free A → fastbin[0x30] (tcache full!)"},
|
| 71 |
+
{"type": "free", "slot": 1, "desc": "Free B → fastbin[0x30], fd → A"},
|
| 72 |
+
{"type": "malloc", "size": 0x20, "slot": 13, "desc": "Malloc → returns from tcache drain"},
|
| 73 |
+
{"type": "malloc", "size": 0x20, "slot": 14, "desc": "Malloc → tcache drain continues"},
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _addr_hex(val):
|
| 80 |
+
"""Format address as short hex string."""
|
| 81 |
+
return f"0x{val:x}" if val else "0x0"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def capture_step(sim, step_idx, op_desc, op_info):
|
| 85 |
+
"""Capture full simulator state for one step."""
|
| 86 |
+
state = sim.get_state()
|
| 87 |
+
prims = sim.check_primitives()
|
| 88 |
+
grid = sim.state_to_grid().tolist()
|
| 89 |
+
|
| 90 |
+
# Build chunk list for visualization
|
| 91 |
+
chunks = []
|
| 92 |
+
for c in state["chunks"]:
|
| 93 |
+
chunks.append({
|
| 94 |
+
"addr": _addr_hex(c["addr"]),
|
| 95 |
+
"addr_int": c["addr"],
|
| 96 |
+
"size": c["size"],
|
| 97 |
+
"size_hex": f"0x{c['size']:x}",
|
| 98 |
+
"allocated": c["allocated"],
|
| 99 |
+
"fd": _addr_hex(c["fd"]),
|
| 100 |
+
"fd_int": c["fd"],
|
| 101 |
+
"bk": _addr_hex(c["bk"]),
|
| 102 |
+
"bk_int": c["bk"],
|
| 103 |
+
"bin": c["bin"],
|
| 104 |
+
"slot": c["slot"],
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
# Build bin structures for visualization
|
| 108 |
+
bins = {
|
| 109 |
+
"tcache": {},
|
| 110 |
+
"fastbin": {},
|
| 111 |
+
"unsorted": [],
|
| 112 |
+
}
|
| 113 |
+
for i, count in enumerate(state["tcache_counts"]):
|
| 114 |
+
if count > 0:
|
| 115 |
+
size_hex = f"0x{(i + 2) * 0x10:x}"
|
| 116 |
+
entries = [_addr_hex(a) for a in sim.tcache[i]]
|
| 117 |
+
bins["tcache"][size_hex] = {"count": count, "entries": entries}
|
| 118 |
+
|
| 119 |
+
for i, count in enumerate(state["fastbin_counts"]):
|
| 120 |
+
if count > 0:
|
| 121 |
+
size_hex = f"0x{(i + 2) * 0x10:x}"
|
| 122 |
+
entries = [_addr_hex(a) for a in sim.fastbins[i]]
|
| 123 |
+
bins["fastbin"][size_hex] = {"count": count, "entries": entries}
|
| 124 |
+
|
| 125 |
+
if state["unsorted_count"] > 0:
|
| 126 |
+
bins["unsorted"] = [_addr_hex(a) for a in sim.unsorted_bin]
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"step": step_idx,
|
| 130 |
+
"action": op_desc,
|
| 131 |
+
"op": op_info,
|
| 132 |
+
"chunks": chunks,
|
| 133 |
+
"bins": bins,
|
| 134 |
+
"primitives": prims,
|
| 135 |
+
"top_addr": _addr_hex(state["top_addr"]),
|
| 136 |
+
"top_size": f"0x{state['top_size']:x}",
|
| 137 |
+
"grid": grid,
|
| 138 |
+
"alloc_count": state["alloc_count"],
|
| 139 |
+
"free_count": state["free_count"],
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def run_scenario(name):
|
| 144 |
+
scenario = SCENARIOS[name]
|
| 145 |
+
sim = HeapSimulator()
|
| 146 |
+
steps = []
|
| 147 |
+
|
| 148 |
+
# Capture initial state
|
| 149 |
+
steps.append(capture_step(sim, 0, "Initial heap state", {"type": "init"}))
|
| 150 |
+
|
| 151 |
+
for i, op in enumerate(scenario["ops"]):
|
| 152 |
+
op_type = op["type"]
|
| 153 |
+
if op_type == "malloc":
|
| 154 |
+
sim.malloc(op["size"], slot=op.get("slot"))
|
| 155 |
+
elif op_type == "free":
|
| 156 |
+
addr = sim.slots.get(op["slot"], 0)
|
| 157 |
+
sim.free(user_addr=addr, slot=op.get("slot"))
|
| 158 |
+
elif op_type == "write_freed":
|
| 159 |
+
addr = sim.slots.get(op["slot"], 0)
|
| 160 |
+
# Find a target address to poison fd with
|
| 161 |
+
# Use an arbitrary address to simulate the attack
|
| 162 |
+
target = 0x41414141
|
| 163 |
+
sim.write_to_freed(addr, target)
|
| 164 |
+
|
| 165 |
+
steps.append(capture_step(sim, i + 1, op["desc"], op))
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"title": scenario["title"],
|
| 169 |
+
"description": scenario["description"],
|
| 170 |
+
"steps": steps,
|
| 171 |
+
"total_steps": len(steps),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def main():
|
| 176 |
+
parser = argparse.ArgumentParser(description="Generate heap-trm visualization data")
|
| 177 |
+
parser.add_argument("-s", "--scenario", default="tcache_poison",
|
| 178 |
+
choices=list(SCENARIOS.keys()),
|
| 179 |
+
help="Exploit scenario to visualize")
|
| 180 |
+
parser.add_argument("-o", "--output", default=None,
|
| 181 |
+
help="Output JSON file (default: viz/data/<scenario>.json)")
|
| 182 |
+
parser.add_argument("--all", action="store_true",
|
| 183 |
+
help="Generate all scenarios")
|
| 184 |
+
args = parser.parse_args()
|
| 185 |
+
|
| 186 |
+
os.makedirs(os.path.join(os.path.dirname(__file__), "data"), exist_ok=True)
|
| 187 |
+
|
| 188 |
+
scenarios_to_run = list(SCENARIOS.keys()) if args.all else [args.scenario]
|
| 189 |
+
|
| 190 |
+
for name in scenarios_to_run:
|
| 191 |
+
result = run_scenario(name)
|
| 192 |
+
out_path = args.output or os.path.join(
|
| 193 |
+
os.path.dirname(__file__), "data", f"{name}.json"
|
| 194 |
+
)
|
| 195 |
+
with open(out_path, "w") as f:
|
| 196 |
+
json.dump(result, f, indent=2)
|
| 197 |
+
print(f"[+] Generated {out_path} ({result['total_steps']} steps)")
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
if __name__ == "__main__":
|
| 201 |
+
main()
|
viz/index.html
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Heap TRM — Exploit Visualizer</title>
|
| 7 |
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0d1117;
|
| 11 |
+
--bg2: #161b22;
|
| 12 |
+
--bg3: #21262d;
|
| 13 |
+
--border: #30363d;
|
| 14 |
+
--text: #e6edf3;
|
| 15 |
+
--text-dim: #8b949e;
|
| 16 |
+
--green: #3fb950;
|
| 17 |
+
--red: #f85149;
|
| 18 |
+
--orange: #d29922;
|
| 19 |
+
--blue: #58a6ff;
|
| 20 |
+
--purple: #bc8cff;
|
| 21 |
+
--cyan: #39d353;
|
| 22 |
+
--pink: #f778ba;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
| 29 |
+
background: var(--bg);
|
| 30 |
+
color: var(--text);
|
| 31 |
+
overflow-x: hidden;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* ---- Top bar ---- */
|
| 35 |
+
header {
|
| 36 |
+
background: var(--bg2);
|
| 37 |
+
border-bottom: 1px solid var(--border);
|
| 38 |
+
padding: 16px 32px;
|
| 39 |
+
display: flex;
|
| 40 |
+
align-items: center;
|
| 41 |
+
justify-content: space-between;
|
| 42 |
+
gap: 24px;
|
| 43 |
+
}
|
| 44 |
+
header h1 {
|
| 45 |
+
font-size: 18px;
|
| 46 |
+
font-weight: 600;
|
| 47 |
+
white-space: nowrap;
|
| 48 |
+
}
|
| 49 |
+
header h1 span { color: var(--blue); }
|
| 50 |
+
.scenario-select {
|
| 51 |
+
background: var(--bg3);
|
| 52 |
+
color: var(--text);
|
| 53 |
+
border: 1px solid var(--border);
|
| 54 |
+
border-radius: 6px;
|
| 55 |
+
padding: 6px 12px;
|
| 56 |
+
font-family: inherit;
|
| 57 |
+
font-size: 13px;
|
| 58 |
+
cursor: pointer;
|
| 59 |
+
}
|
| 60 |
+
.scenario-desc {
|
| 61 |
+
font-size: 12px;
|
| 62 |
+
color: var(--text-dim);
|
| 63 |
+
max-width: 500px;
|
| 64 |
+
line-height: 1.4;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ---- Controls ---- */
|
| 68 |
+
.controls {
|
| 69 |
+
background: var(--bg2);
|
| 70 |
+
border-bottom: 1px solid var(--border);
|
| 71 |
+
padding: 12px 32px;
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
gap: 16px;
|
| 75 |
+
}
|
| 76 |
+
.controls button {
|
| 77 |
+
background: var(--bg3);
|
| 78 |
+
color: var(--text);
|
| 79 |
+
border: 1px solid var(--border);
|
| 80 |
+
border-radius: 6px;
|
| 81 |
+
padding: 6px 14px;
|
| 82 |
+
font-family: inherit;
|
| 83 |
+
font-size: 13px;
|
| 84 |
+
cursor: pointer;
|
| 85 |
+
transition: background 0.15s;
|
| 86 |
+
}
|
| 87 |
+
.controls button:hover { background: var(--border); }
|
| 88 |
+
.controls button.active { background: var(--blue); color: #000; border-color: var(--blue); }
|
| 89 |
+
.step-slider {
|
| 90 |
+
flex: 1;
|
| 91 |
+
-webkit-appearance: none;
|
| 92 |
+
appearance: none;
|
| 93 |
+
height: 6px;
|
| 94 |
+
background: var(--bg3);
|
| 95 |
+
border-radius: 3px;
|
| 96 |
+
outline: none;
|
| 97 |
+
}
|
| 98 |
+
.step-slider::-webkit-slider-thumb {
|
| 99 |
+
-webkit-appearance: none;
|
| 100 |
+
width: 18px; height: 18px;
|
| 101 |
+
background: var(--blue);
|
| 102 |
+
border-radius: 50%;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
}
|
| 105 |
+
.step-label {
|
| 106 |
+
font-size: 13px;
|
| 107 |
+
color: var(--text-dim);
|
| 108 |
+
min-width: 80px;
|
| 109 |
+
text-align: center;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* ---- Main layout ---- */
|
| 113 |
+
.main {
|
| 114 |
+
display: grid;
|
| 115 |
+
grid-template-columns: 1fr 320px;
|
| 116 |
+
grid-template-rows: auto 1fr;
|
| 117 |
+
height: calc(100vh - 100px);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Action banner */
|
| 121 |
+
.action-banner {
|
| 122 |
+
grid-column: 1 / -1;
|
| 123 |
+
background: var(--bg2);
|
| 124 |
+
border-bottom: 1px solid var(--border);
|
| 125 |
+
padding: 12px 32px;
|
| 126 |
+
font-size: 14px;
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: center;
|
| 129 |
+
gap: 12px;
|
| 130 |
+
}
|
| 131 |
+
.action-banner .op-badge {
|
| 132 |
+
padding: 3px 10px;
|
| 133 |
+
border-radius: 12px;
|
| 134 |
+
font-size: 11px;
|
| 135 |
+
font-weight: 700;
|
| 136 |
+
text-transform: uppercase;
|
| 137 |
+
letter-spacing: 0.5px;
|
| 138 |
+
}
|
| 139 |
+
.op-malloc { background: rgba(63,185,80,0.2); color: var(--green); }
|
| 140 |
+
.op-free { background: rgba(248,81,73,0.2); color: var(--red); }
|
| 141 |
+
.op-write_freed { background: rgba(210,153,34,0.2); color: var(--orange); }
|
| 142 |
+
.op-init { background: rgba(88,166,255,0.2); color: var(--blue); }
|
| 143 |
+
|
| 144 |
+
/* Heap canvas area */
|
| 145 |
+
.heap-canvas {
|
| 146 |
+
padding: 24px 32px;
|
| 147 |
+
overflow-y: auto;
|
| 148 |
+
}
|
| 149 |
+
.heap-canvas h2 {
|
| 150 |
+
font-size: 13px;
|
| 151 |
+
color: var(--text-dim);
|
| 152 |
+
text-transform: uppercase;
|
| 153 |
+
letter-spacing: 1px;
|
| 154 |
+
margin-bottom: 16px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* Right sidebar */
|
| 158 |
+
.sidebar {
|
| 159 |
+
background: var(--bg2);
|
| 160 |
+
border-left: 1px solid var(--border);
|
| 161 |
+
padding: 20px;
|
| 162 |
+
overflow-y: auto;
|
| 163 |
+
display: flex;
|
| 164 |
+
flex-direction: column;
|
| 165 |
+
gap: 20px;
|
| 166 |
+
}
|
| 167 |
+
.sidebar h3 {
|
| 168 |
+
font-size: 12px;
|
| 169 |
+
color: var(--text-dim);
|
| 170 |
+
text-transform: uppercase;
|
| 171 |
+
letter-spacing: 1px;
|
| 172 |
+
margin-bottom: 8px;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* Primitives panel */
|
| 176 |
+
.primitives-list {
|
| 177 |
+
display: flex;
|
| 178 |
+
flex-direction: column;
|
| 179 |
+
gap: 4px;
|
| 180 |
+
}
|
| 181 |
+
.prim-item {
|
| 182 |
+
display: flex;
|
| 183 |
+
align-items: center;
|
| 184 |
+
gap: 8px;
|
| 185 |
+
font-size: 12px;
|
| 186 |
+
padding: 4px 8px;
|
| 187 |
+
border-radius: 4px;
|
| 188 |
+
background: var(--bg3);
|
| 189 |
+
}
|
| 190 |
+
.prim-dot {
|
| 191 |
+
width: 8px;
|
| 192 |
+
height: 8px;
|
| 193 |
+
border-radius: 50%;
|
| 194 |
+
flex-shrink: 0;
|
| 195 |
+
}
|
| 196 |
+
.prim-dot.active { background: var(--red); box-shadow: 0 0 8px var(--red); }
|
| 197 |
+
.prim-dot.inactive { background: var(--bg); border: 1px solid var(--border); }
|
| 198 |
+
.prim-item.active { background: rgba(248,81,73,0.1); color: var(--red); font-weight: 600; }
|
| 199 |
+
|
| 200 |
+
/* Bins panel */
|
| 201 |
+
.bin-section { margin-bottom: 8px; }
|
| 202 |
+
.bin-label {
|
| 203 |
+
font-size: 11px;
|
| 204 |
+
color: var(--purple);
|
| 205 |
+
margin-bottom: 4px;
|
| 206 |
+
}
|
| 207 |
+
.bin-chain {
|
| 208 |
+
display: flex;
|
| 209 |
+
align-items: center;
|
| 210 |
+
gap: 4px;
|
| 211 |
+
flex-wrap: wrap;
|
| 212 |
+
font-size: 11px;
|
| 213 |
+
}
|
| 214 |
+
.bin-node {
|
| 215 |
+
background: var(--bg3);
|
| 216 |
+
border: 1px solid var(--border);
|
| 217 |
+
border-radius: 4px;
|
| 218 |
+
padding: 2px 6px;
|
| 219 |
+
font-size: 10px;
|
| 220 |
+
}
|
| 221 |
+
.bin-arrow { color: var(--text-dim); }
|
| 222 |
+
.bin-empty {
|
| 223 |
+
font-size: 11px;
|
| 224 |
+
color: var(--text-dim);
|
| 225 |
+
font-style: italic;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/* Grid heatmap */
|
| 229 |
+
.grid-container {
|
| 230 |
+
overflow-x: auto;
|
| 231 |
+
}
|
| 232 |
+
.grid-container canvas {
|
| 233 |
+
image-rendering: pixelated;
|
| 234 |
+
border: 1px solid var(--border);
|
| 235 |
+
border-radius: 4px;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/* Step timeline */
|
| 239 |
+
.timeline {
|
| 240 |
+
grid-column: 1 / -1;
|
| 241 |
+
background: var(--bg2);
|
| 242 |
+
border-top: 1px solid var(--border);
|
| 243 |
+
padding: 8px 32px;
|
| 244 |
+
display: flex;
|
| 245 |
+
gap: 2px;
|
| 246 |
+
align-items: center;
|
| 247 |
+
overflow-x: auto;
|
| 248 |
+
}
|
| 249 |
+
.timeline-dot {
|
| 250 |
+
width: 28px;
|
| 251 |
+
height: 28px;
|
| 252 |
+
border-radius: 50%;
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
justify-content: center;
|
| 256 |
+
font-size: 10px;
|
| 257 |
+
cursor: pointer;
|
| 258 |
+
border: 2px solid transparent;
|
| 259 |
+
transition: all 0.15s;
|
| 260 |
+
flex-shrink: 0;
|
| 261 |
+
}
|
| 262 |
+
.timeline-dot:hover { border-color: var(--text-dim); }
|
| 263 |
+
.timeline-dot.current { border-color: var(--blue); background: rgba(88,166,255,0.2); }
|
| 264 |
+
.timeline-dot.has-primitive { box-shadow: 0 0 6px var(--red); }
|
| 265 |
+
.timeline-connector {
|
| 266 |
+
width: 16px;
|
| 267 |
+
height: 2px;
|
| 268 |
+
background: var(--border);
|
| 269 |
+
flex-shrink: 0;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
/* SVG styles */
|
| 273 |
+
.chunk-rect { stroke-width: 2; rx: 4; ry: 4; }
|
| 274 |
+
.chunk-allocated { fill: rgba(63,185,80,0.15); stroke: var(--green); }
|
| 275 |
+
.chunk-freed { fill: rgba(248,81,73,0.15); stroke: var(--red); }
|
| 276 |
+
.chunk-label { fill: var(--text); font-size: 12px; font-family: inherit; }
|
| 277 |
+
.chunk-sublabel { fill: var(--text-dim); font-size: 10px; font-family: inherit; }
|
| 278 |
+
.chunk-addr { fill: var(--text-dim); font-size: 10px; font-family: inherit; }
|
| 279 |
+
.fd-arrow { stroke: var(--orange); stroke-width: 2; fill: none; marker-end: url(#arrowhead-fd); }
|
| 280 |
+
.fd-arrow-poison { stroke: var(--red); stroke-width: 2.5; fill: none; stroke-dasharray: 6,3; marker-end: url(#arrowhead-poison); }
|
| 281 |
+
.top-chunk-rect { fill: rgba(88,166,255,0.08); stroke: var(--blue); stroke-dasharray: 4,4; stroke-width: 1.5; rx: 4; ry: 4; }
|
| 282 |
+
.top-label { fill: var(--blue); font-size: 11px; font-family: inherit; }
|
| 283 |
+
|
| 284 |
+
/* Chunk highlight for newly changed */
|
| 285 |
+
.chunk-highlight {
|
| 286 |
+
animation: pulse 0.6s ease-out;
|
| 287 |
+
}
|
| 288 |
+
@keyframes pulse {
|
| 289 |
+
0% { filter: brightness(1.8); }
|
| 290 |
+
100% { filter: brightness(1); }
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
/* Bin visualizer in main area */
|
| 294 |
+
.bins-viz {
|
| 295 |
+
margin-top: 24px;
|
| 296 |
+
}
|
| 297 |
+
.bins-viz h2 {
|
| 298 |
+
font-size: 13px;
|
| 299 |
+
color: var(--text-dim);
|
| 300 |
+
text-transform: uppercase;
|
| 301 |
+
letter-spacing: 1px;
|
| 302 |
+
margin-bottom: 16px;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
/* Responsive */
|
| 306 |
+
@media (max-width: 900px) {
|
| 307 |
+
.main { grid-template-columns: 1fr; }
|
| 308 |
+
.sidebar { border-left: none; border-top: 1px solid var(--border); }
|
| 309 |
+
}
|
| 310 |
+
</style>
|
| 311 |
+
</head>
|
| 312 |
+
<body>
|
| 313 |
+
|
| 314 |
+
<header>
|
| 315 |
+
<h1><span>Heap TRM</span> — Exploit Visualizer</h1>
|
| 316 |
+
<select class="scenario-select" id="scenarioSelect"></select>
|
| 317 |
+
<div class="scenario-desc" id="scenarioDesc"></div>
|
| 318 |
+
</header>
|
| 319 |
+
|
| 320 |
+
<div class="controls">
|
| 321 |
+
<button id="btnPrev" title="Previous step">◀ Prev</button>
|
| 322 |
+
<button id="btnPlay" title="Play/Pause">▶ Play</button>
|
| 323 |
+
<button id="btnNext" title="Next step">Next ▶</button>
|
| 324 |
+
<input type="range" class="step-slider" id="stepSlider" min="0" max="8" value="0">
|
| 325 |
+
<div class="step-label" id="stepLabel">Step 0 / 8</div>
|
| 326 |
+
<button id="btnSpeed">1x</button>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<div class="main">
|
| 330 |
+
<div class="action-banner" id="actionBanner"></div>
|
| 331 |
+
|
| 332 |
+
<div class="heap-canvas" id="heapCanvas">
|
| 333 |
+
<h2>Heap Memory Layout</h2>
|
| 334 |
+
<svg id="heapSvg" width="100%" height="500"></svg>
|
| 335 |
+
<div class="bins-viz">
|
| 336 |
+
<h2>Bin Structures</h2>
|
| 337 |
+
<svg id="binsSvg" width="100%" height="200"></svg>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
|
| 341 |
+
<div class="sidebar">
|
| 342 |
+
<div>
|
| 343 |
+
<h3>Exploit Primitives</h3>
|
| 344 |
+
<div class="primitives-list" id="primList"></div>
|
| 345 |
+
</div>
|
| 346 |
+
<div>
|
| 347 |
+
<h3>Heap Stats</h3>
|
| 348 |
+
<div id="heapStats" style="font-size:12px; color:var(--text-dim);"></div>
|
| 349 |
+
</div>
|
| 350 |
+
<div>
|
| 351 |
+
<h3>Model Input Grid (32x16)</h3>
|
| 352 |
+
<div class="grid-container">
|
| 353 |
+
<canvas id="gridCanvas" width="160" height="320"></canvas>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div class="timeline" id="timeline"></div>
|
| 360 |
+
|
| 361 |
+
<script>
|
| 362 |
+
// ============================================================
|
| 363 |
+
// State
|
| 364 |
+
// ============================================================
|
| 365 |
+
let scenarios = {};
|
| 366 |
+
let currentScenario = null;
|
| 367 |
+
let currentStep = 0;
|
| 368 |
+
let playing = false;
|
| 369 |
+
let playInterval = null;
|
| 370 |
+
let speed = 1;
|
| 371 |
+
const speeds = [1, 2, 0.5];
|
| 372 |
+
|
| 373 |
+
// ============================================================
|
| 374 |
+
// Load data
|
| 375 |
+
// ============================================================
|
| 376 |
+
async function loadScenario(name) {
|
| 377 |
+
if (!scenarios[name]) {
|
| 378 |
+
const resp = await fetch(`data/${name}.json`);
|
| 379 |
+
scenarios[name] = await resp.json();
|
| 380 |
+
}
|
| 381 |
+
currentScenario = scenarios[name];
|
| 382 |
+
currentStep = 0;
|
| 383 |
+
initUI();
|
| 384 |
+
renderStep();
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
// ============================================================
|
| 388 |
+
// UI Init
|
| 389 |
+
// ============================================================
|
| 390 |
+
function initUI() {
|
| 391 |
+
const data = currentScenario;
|
| 392 |
+
document.getElementById('scenarioDesc').textContent = data.description;
|
| 393 |
+
const slider = document.getElementById('stepSlider');
|
| 394 |
+
slider.max = data.total_steps - 1;
|
| 395 |
+
slider.value = 0;
|
| 396 |
+
buildTimeline();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function buildTimeline() {
|
| 400 |
+
const tl = document.getElementById('timeline');
|
| 401 |
+
tl.innerHTML = '';
|
| 402 |
+
const data = currentScenario;
|
| 403 |
+
data.steps.forEach((step, i) => {
|
| 404 |
+
if (i > 0) {
|
| 405 |
+
const conn = document.createElement('div');
|
| 406 |
+
conn.className = 'timeline-connector';
|
| 407 |
+
tl.appendChild(conn);
|
| 408 |
+
}
|
| 409 |
+
const dot = document.createElement('div');
|
| 410 |
+
dot.className = 'timeline-dot';
|
| 411 |
+
const hasPrim = Object.values(step.primitives).some(v => v);
|
| 412 |
+
if (hasPrim) dot.classList.add('has-primitive');
|
| 413 |
+
const opType = step.op.type;
|
| 414 |
+
const colors = { malloc: 'var(--green)', free: 'var(--red)', write_freed: 'var(--orange)', init: 'var(--blue)' };
|
| 415 |
+
dot.style.background = (colors[opType] || 'var(--bg3)').replace('var(', '').replace(')', '');
|
| 416 |
+
// Use actual CSS var
|
| 417 |
+
dot.style.background = `color-mix(in srgb, ${colors[opType] || 'var(--bg3)'} 30%, transparent)`;
|
| 418 |
+
dot.style.color = colors[opType] || 'var(--text-dim)';
|
| 419 |
+
dot.textContent = i;
|
| 420 |
+
dot.addEventListener('click', () => { currentStep = i; renderStep(); });
|
| 421 |
+
tl.appendChild(dot);
|
| 422 |
+
});
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ============================================================
|
| 426 |
+
// Render current step
|
| 427 |
+
// ============================================================
|
| 428 |
+
function renderStep() {
|
| 429 |
+
const data = currentScenario;
|
| 430 |
+
const step = data.steps[currentStep];
|
| 431 |
+
|
| 432 |
+
// Update controls
|
| 433 |
+
document.getElementById('stepSlider').value = currentStep;
|
| 434 |
+
document.getElementById('stepLabel').textContent = `Step ${currentStep} / ${data.total_steps - 1}`;
|
| 435 |
+
|
| 436 |
+
// Timeline
|
| 437 |
+
document.querySelectorAll('.timeline-dot').forEach((d, i) => {
|
| 438 |
+
d.classList.toggle('current', i === currentStep);
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
// Action banner
|
| 442 |
+
renderActionBanner(step);
|
| 443 |
+
|
| 444 |
+
// Heap SVG
|
| 445 |
+
renderHeap(step);
|
| 446 |
+
|
| 447 |
+
// Bins SVG
|
| 448 |
+
renderBins(step);
|
| 449 |
+
|
| 450 |
+
// Primitives
|
| 451 |
+
renderPrimitives(step);
|
| 452 |
+
|
| 453 |
+
// Stats
|
| 454 |
+
renderStats(step);
|
| 455 |
+
|
| 456 |
+
// Grid heatmap
|
| 457 |
+
renderGrid(step);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function renderActionBanner(step) {
|
| 461 |
+
const banner = document.getElementById('actionBanner');
|
| 462 |
+
const opType = step.op.type;
|
| 463 |
+
banner.innerHTML = `
|
| 464 |
+
<span class="op-badge op-${opType}">${opType}</span>
|
| 465 |
+
<span>${step.action}</span>
|
| 466 |
+
`;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
// ============================================================
|
| 470 |
+
// Heap Memory SVG
|
| 471 |
+
// ============================================================
|
| 472 |
+
function renderHeap(step) {
|
| 473 |
+
const svg = d3.select('#heapSvg');
|
| 474 |
+
svg.selectAll('*').remove();
|
| 475 |
+
|
| 476 |
+
const chunks = step.chunks;
|
| 477 |
+
if (chunks.length === 0) {
|
| 478 |
+
svg.attr('height', 120);
|
| 479 |
+
svg.append('text')
|
| 480 |
+
.attr('x', 20).attr('y', 50)
|
| 481 |
+
.attr('class', 'chunk-label')
|
| 482 |
+
.attr('fill', 'var(--text-dim)')
|
| 483 |
+
.text('Empty heap — no chunks allocated yet');
|
| 484 |
+
return;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const chunkW = 260;
|
| 488 |
+
const chunkH = 64;
|
| 489 |
+
const gap = 8;
|
| 490 |
+
const leftPad = 120;
|
| 491 |
+
const topPad = 20;
|
| 492 |
+
const totalH = topPad + chunks.length * (chunkH + gap) + 80;
|
| 493 |
+
svg.attr('height', totalH);
|
| 494 |
+
|
| 495 |
+
// Defs for arrowheads
|
| 496 |
+
const defs = svg.append('defs');
|
| 497 |
+
defs.append('marker').attr('id', 'arrowhead-fd')
|
| 498 |
+
.attr('viewBox', '0 0 10 10').attr('refX', 10).attr('refY', 5)
|
| 499 |
+
.attr('markerWidth', 8).attr('markerHeight', 8).attr('orient', 'auto')
|
| 500 |
+
.append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', 'var(--orange)');
|
| 501 |
+
defs.append('marker').attr('id', 'arrowhead-poison')
|
| 502 |
+
.attr('viewBox', '0 0 10 10').attr('refX', 10).attr('refY', 5)
|
| 503 |
+
.attr('markerWidth', 8).attr('markerHeight', 8).attr('orient', 'auto')
|
| 504 |
+
.append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', 'var(--red)');
|
| 505 |
+
|
| 506 |
+
// Chunk y-positions
|
| 507 |
+
const chunkY = (i) => topPad + i * (chunkH + gap);
|
| 508 |
+
|
| 509 |
+
// Draw chunks
|
| 510 |
+
chunks.forEach((chunk, i) => {
|
| 511 |
+
const g = svg.append('g')
|
| 512 |
+
.attr('transform', `translate(${leftPad}, ${chunkY(i)})`)
|
| 513 |
+
.attr('class', 'chunk-highlight');
|
| 514 |
+
|
| 515 |
+
// Determine if this chunk changed since last step
|
| 516 |
+
const prevStep = currentStep > 0 ? currentScenario.steps[currentStep - 1] : null;
|
| 517 |
+
let changed = false;
|
| 518 |
+
if (prevStep) {
|
| 519 |
+
const prevChunk = prevStep.chunks[i];
|
| 520 |
+
if (!prevChunk || prevChunk.allocated !== chunk.allocated || prevChunk.fd !== chunk.fd) {
|
| 521 |
+
changed = true;
|
| 522 |
+
}
|
| 523 |
+
} else if (currentStep === 0) {
|
| 524 |
+
changed = false;
|
| 525 |
+
} else {
|
| 526 |
+
changed = true;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Background rect
|
| 530 |
+
g.append('rect')
|
| 531 |
+
.attr('width', chunkW).attr('height', chunkH)
|
| 532 |
+
.attr('class', `chunk-rect ${chunk.allocated ? 'chunk-allocated' : 'chunk-freed'}`)
|
| 533 |
+
.style('filter', changed ? 'brightness(1.4)' : 'none');
|
| 534 |
+
|
| 535 |
+
// Slot label on left
|
| 536 |
+
if (chunk.slot !== null && chunk.slot !== undefined) {
|
| 537 |
+
svg.append('text')
|
| 538 |
+
.attr('x', leftPad - 10).attr('y', chunkY(i) + chunkH / 2 + 4)
|
| 539 |
+
.attr('text-anchor', 'end')
|
| 540 |
+
.attr('class', 'chunk-sublabel')
|
| 541 |
+
.text(`slot ${chunk.slot}`);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
// Address on far left
|
| 545 |
+
svg.append('text')
|
| 546 |
+
.attr('x', 8).attr('y', chunkY(i) + chunkH / 2 + 4)
|
| 547 |
+
.attr('class', 'chunk-addr')
|
| 548 |
+
.text(chunk.addr);
|
| 549 |
+
|
| 550 |
+
// Main labels inside chunk
|
| 551 |
+
g.append('text')
|
| 552 |
+
.attr('x', 12).attr('y', 22)
|
| 553 |
+
.attr('class', 'chunk-label')
|
| 554 |
+
.text(`Chunk ${i}`);
|
| 555 |
+
|
| 556 |
+
g.append('text')
|
| 557 |
+
.attr('x', 12).attr('y', 40)
|
| 558 |
+
.attr('class', 'chunk-sublabel')
|
| 559 |
+
.text(`size: ${chunk.size_hex} | ${chunk.allocated ? 'ALLOCATED' : 'FREED'}`);
|
| 560 |
+
|
| 561 |
+
if (!chunk.allocated && chunk.fd !== '0x0') {
|
| 562 |
+
g.append('text')
|
| 563 |
+
.attr('x', 12).attr('y', 56)
|
| 564 |
+
.attr('class', 'chunk-sublabel')
|
| 565 |
+
.style('fill', chunk.fd_int > 0x10000000 ? 'var(--red)' : 'var(--orange)')
|
| 566 |
+
.text(`fd: ${chunk.fd}`);
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// Bin tag
|
| 570 |
+
if (chunk.bin && chunk.bin !== 'none') {
|
| 571 |
+
const tagW = chunk.bin.length * 7 + 12;
|
| 572 |
+
g.append('rect')
|
| 573 |
+
.attr('x', chunkW - tagW - 8).attr('y', 8)
|
| 574 |
+
.attr('width', tagW).attr('height', 18)
|
| 575 |
+
.attr('rx', 9).attr('ry', 9)
|
| 576 |
+
.attr('fill', chunk.bin.startsWith('tcache') ? 'rgba(188,140,255,0.2)' :
|
| 577 |
+
chunk.bin.startsWith('fastbin') ? 'rgba(57,211,83,0.2)' :
|
| 578 |
+
'rgba(88,166,255,0.2)')
|
| 579 |
+
.attr('stroke', chunk.bin.startsWith('tcache') ? 'var(--purple)' :
|
| 580 |
+
chunk.bin.startsWith('fastbin') ? 'var(--cyan)' :
|
| 581 |
+
'var(--blue)')
|
| 582 |
+
.attr('stroke-width', 1);
|
| 583 |
+
g.append('text')
|
| 584 |
+
.attr('x', chunkW - tagW / 2 - 8).attr('y', 21)
|
| 585 |
+
.attr('text-anchor', 'middle')
|
| 586 |
+
.attr('font-size', '10px')
|
| 587 |
+
.attr('fill', chunk.bin.startsWith('tcache') ? 'var(--purple)' :
|
| 588 |
+
chunk.bin.startsWith('fastbin') ? 'var(--cyan)' :
|
| 589 |
+
'var(--blue)')
|
| 590 |
+
.text(chunk.bin);
|
| 591 |
+
}
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
// Draw fd pointer arrows
|
| 595 |
+
chunks.forEach((chunk, i) => {
|
| 596 |
+
if (chunk.allocated || chunk.fd === '0x0') return;
|
| 597 |
+
|
| 598 |
+
const fromX = leftPad + chunkW + 8;
|
| 599 |
+
const fromY = chunkY(i) + chunkH / 2;
|
| 600 |
+
|
| 601 |
+
// Find target chunk by address
|
| 602 |
+
const targetIdx = chunks.findIndex(c => c.addr_int === chunk.fd_int);
|
| 603 |
+
const isPoisoned = chunk.fd_int > 0x10000000 || (targetIdx === -1 && chunk.fd_int !== 0);
|
| 604 |
+
|
| 605 |
+
if (targetIdx >= 0) {
|
| 606 |
+
// Arrow to another chunk
|
| 607 |
+
const toY = chunkY(targetIdx) + chunkH / 2;
|
| 608 |
+
const midX = fromX + 40;
|
| 609 |
+
svg.append('path')
|
| 610 |
+
.attr('d', `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${leftPad + chunkW} ${toY}`)
|
| 611 |
+
.attr('class', isPoisoned ? 'fd-arrow-poison' : 'fd-arrow');
|
| 612 |
+
} else if (isPoisoned) {
|
| 613 |
+
// Poisoned pointer going off-screen
|
| 614 |
+
svg.append('path')
|
| 615 |
+
.attr('d', `M ${fromX} ${fromY} L ${fromX + 60} ${fromY - 20}`)
|
| 616 |
+
.attr('class', 'fd-arrow-poison');
|
| 617 |
+
svg.append('text')
|
| 618 |
+
.attr('x', fromX + 65).attr('y', fromY - 22)
|
| 619 |
+
.attr('font-size', '10px')
|
| 620 |
+
.attr('fill', 'var(--red)')
|
| 621 |
+
.attr('font-weight', '700')
|
| 622 |
+
.text(chunk.fd);
|
| 623 |
+
}
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
// Top chunk
|
| 627 |
+
const topY = chunkY(chunks.length);
|
| 628 |
+
svg.append('rect')
|
| 629 |
+
.attr('x', leftPad).attr('y', topY)
|
| 630 |
+
.attr('width', chunkW).attr('height', 40)
|
| 631 |
+
.attr('class', 'top-chunk-rect');
|
| 632 |
+
svg.append('text')
|
| 633 |
+
.attr('x', leftPad + 12).attr('y', topY + 16)
|
| 634 |
+
.attr('class', 'top-label')
|
| 635 |
+
.text(`TOP CHUNK`);
|
| 636 |
+
svg.append('text')
|
| 637 |
+
.attr('x', leftPad + 12).attr('y', topY + 32)
|
| 638 |
+
.attr('font-size', '10px')
|
| 639 |
+
.attr('fill', 'var(--text-dim)')
|
| 640 |
+
.text(`${step.top_addr} (${step.top_size})`);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
// ============================================================
|
| 644 |
+
// Bin structures SVG
|
| 645 |
+
// ============================================================
|
| 646 |
+
function renderBins(step) {
|
| 647 |
+
const svg = d3.select('#binsSvg');
|
| 648 |
+
svg.selectAll('*').remove();
|
| 649 |
+
|
| 650 |
+
const bins = step.bins;
|
| 651 |
+
let y = 10;
|
| 652 |
+
const nodeW = 72;
|
| 653 |
+
const nodeH = 28;
|
| 654 |
+
const arrowW = 24;
|
| 655 |
+
const leftPad = 100;
|
| 656 |
+
|
| 657 |
+
function drawChain(label, color, entries, yPos) {
|
| 658 |
+
svg.append('text')
|
| 659 |
+
.attr('x', 8).attr('y', yPos + nodeH / 2 + 4)
|
| 660 |
+
.attr('font-size', '11px')
|
| 661 |
+
.attr('fill', color)
|
| 662 |
+
.attr('font-weight', '600')
|
| 663 |
+
.text(label);
|
| 664 |
+
|
| 665 |
+
if (entries.length === 0) {
|
| 666 |
+
svg.append('text')
|
| 667 |
+
.attr('x', leftPad).attr('y', yPos + nodeH / 2 + 4)
|
| 668 |
+
.attr('font-size', '11px')
|
| 669 |
+
.attr('fill', 'var(--text-dim)')
|
| 670 |
+
.attr('font-style', 'italic')
|
| 671 |
+
.text('empty');
|
| 672 |
+
return nodeH + 8;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
entries.forEach((addr, j) => {
|
| 676 |
+
const x = leftPad + j * (nodeW + arrowW);
|
| 677 |
+
svg.append('rect')
|
| 678 |
+
.attr('x', x).attr('y', yPos)
|
| 679 |
+
.attr('width', nodeW).attr('height', nodeH)
|
| 680 |
+
.attr('rx', 4).attr('ry', 4)
|
| 681 |
+
.attr('fill', 'var(--bg3)')
|
| 682 |
+
.attr('stroke', color)
|
| 683 |
+
.attr('stroke-width', 1);
|
| 684 |
+
svg.append('text')
|
| 685 |
+
.attr('x', x + nodeW / 2).attr('y', yPos + nodeH / 2 + 4)
|
| 686 |
+
.attr('text-anchor', 'middle')
|
| 687 |
+
.attr('font-size', '10px')
|
| 688 |
+
.attr('fill', 'var(--text)')
|
| 689 |
+
.text(addr);
|
| 690 |
+
|
| 691 |
+
if (j < entries.length - 1) {
|
| 692 |
+
const ax = x + nodeW + 4;
|
| 693 |
+
svg.append('line')
|
| 694 |
+
.attr('x1', ax).attr('y1', yPos + nodeH / 2)
|
| 695 |
+
.attr('x2', ax + arrowW - 8).attr('y2', yPos + nodeH / 2)
|
| 696 |
+
.attr('stroke', 'var(--text-dim)').attr('stroke-width', 1.5)
|
| 697 |
+
.attr('marker-end', 'url(#arrowhead-fd)');
|
| 698 |
+
}
|
| 699 |
+
});
|
| 700 |
+
|
| 701 |
+
// NULL terminator
|
| 702 |
+
const nullX = leftPad + entries.length * (nodeW + arrowW);
|
| 703 |
+
svg.append('text')
|
| 704 |
+
.attr('x', nullX).attr('y', yPos + nodeH / 2 + 4)
|
| 705 |
+
.attr('font-size', '11px')
|
| 706 |
+
.attr('fill', 'var(--text-dim)')
|
| 707 |
+
.text('NULL');
|
| 708 |
+
|
| 709 |
+
return nodeH + 8;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// Defs
|
| 713 |
+
const defs = svg.append('defs');
|
| 714 |
+
defs.append('marker').attr('id', 'arrowhead-bin')
|
| 715 |
+
.attr('viewBox', '0 0 10 10').attr('refX', 10).attr('refY', 5)
|
| 716 |
+
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
|
| 717 |
+
.append('path').attr('d', 'M 0 0 L 10 5 L 0 10 z').attr('fill', 'var(--text-dim)');
|
| 718 |
+
|
| 719 |
+
// Tcache bins
|
| 720 |
+
for (const [size, info] of Object.entries(bins.tcache)) {
|
| 721 |
+
y += drawChain(`tcache[${size}]`, 'var(--purple)', info.entries, y);
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
// Fastbins
|
| 725 |
+
for (const [size, info] of Object.entries(bins.fastbin)) {
|
| 726 |
+
y += drawChain(`fastbin[${size}]`, 'var(--cyan)', info.entries, y);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
// Unsorted
|
| 730 |
+
if (bins.unsorted && bins.unsorted.length > 0) {
|
| 731 |
+
y += drawChain('unsorted', 'var(--blue)', bins.unsorted, y);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
if (y === 10) {
|
| 735 |
+
svg.append('text')
|
| 736 |
+
.attr('x', 8).attr('y', 30)
|
| 737 |
+
.attr('font-size', '12px')
|
| 738 |
+
.attr('fill', 'var(--text-dim)')
|
| 739 |
+
.text('No active bin entries');
|
| 740 |
+
y = 50;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
svg.attr('height', y + 10);
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// ============================================================
|
| 747 |
+
// Primitives
|
| 748 |
+
// ============================================================
|
| 749 |
+
const PRIM_LABELS = {
|
| 750 |
+
overlapping_chunks: 'Overlapping Chunks',
|
| 751 |
+
arbitrary_alloc: 'Arbitrary Allocation',
|
| 752 |
+
double_free: 'Double Free',
|
| 753 |
+
tcache_poison: 'Tcache Poison',
|
| 754 |
+
freelist_cycle: 'Freelist Cycle',
|
| 755 |
+
duplicate_alloc: 'Duplicate Allocation',
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
function renderPrimitives(step) {
|
| 759 |
+
const container = document.getElementById('primList');
|
| 760 |
+
container.innerHTML = '';
|
| 761 |
+
for (const [key, label] of Object.entries(PRIM_LABELS)) {
|
| 762 |
+
const active = step.primitives[key];
|
| 763 |
+
const div = document.createElement('div');
|
| 764 |
+
div.className = `prim-item ${active ? 'active' : ''}`;
|
| 765 |
+
div.innerHTML = `<span class="prim-dot ${active ? 'active' : 'inactive'}"></span>${label}`;
|
| 766 |
+
container.appendChild(div);
|
| 767 |
+
}
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
// ============================================================
|
| 771 |
+
// Stats
|
| 772 |
+
// ============================================================
|
| 773 |
+
function renderStats(step) {
|
| 774 |
+
document.getElementById('heapStats').innerHTML = `
|
| 775 |
+
<div style="display:grid; grid-template-columns:1fr 1fr; gap:4px 12px;">
|
| 776 |
+
<span>Allocations:</span><span style="color:var(--green)">${step.alloc_count}</span>
|
| 777 |
+
<span>Frees:</span><span style="color:var(--red)">${step.free_count}</span>
|
| 778 |
+
<span>Live chunks:</span><span>${step.chunks.filter(c => c.allocated).length}</span>
|
| 779 |
+
<span>Freed chunks:</span><span>${step.chunks.filter(c => !c.allocated).length}</span>
|
| 780 |
+
<span>Top chunk:</span><span style="font-size:10px">${step.top_size}</span>
|
| 781 |
+
</div>
|
| 782 |
+
`;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
// ============================================================
|
| 786 |
+
// Grid heatmap (model input)
|
| 787 |
+
// ============================================================
|
| 788 |
+
function renderGrid(step) {
|
| 789 |
+
const canvas = document.getElementById('gridCanvas');
|
| 790 |
+
const ctx = canvas.getContext('2d');
|
| 791 |
+
const grid = step.grid;
|
| 792 |
+
const cellW = 10;
|
| 793 |
+
const cellH = 10;
|
| 794 |
+
canvas.width = 16 * cellW;
|
| 795 |
+
canvas.height = 32 * cellH;
|
| 796 |
+
|
| 797 |
+
const colorScale = d3.scaleSequential(d3.interpolateViridis).domain([0, 63]);
|
| 798 |
+
|
| 799 |
+
for (let r = 0; r < 32; r++) {
|
| 800 |
+
for (let c = 0; c < 16; c++) {
|
| 801 |
+
const val = grid[r][c];
|
| 802 |
+
ctx.fillStyle = val === 0 ? '#0d1117' : colorScale(val);
|
| 803 |
+
ctx.fillRect(c * cellW, r * cellH, cellW, cellH);
|
| 804 |
+
}
|
| 805 |
+
}
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
// ============================================================
|
| 809 |
+
// Controls
|
| 810 |
+
// ============================================================
|
| 811 |
+
document.getElementById('btnPrev').addEventListener('click', () => {
|
| 812 |
+
if (currentStep > 0) { currentStep--; renderStep(); }
|
| 813 |
+
});
|
| 814 |
+
document.getElementById('btnNext').addEventListener('click', () => {
|
| 815 |
+
if (currentStep < currentScenario.total_steps - 1) { currentStep++; renderStep(); }
|
| 816 |
+
});
|
| 817 |
+
document.getElementById('stepSlider').addEventListener('input', (e) => {
|
| 818 |
+
currentStep = parseInt(e.target.value);
|
| 819 |
+
renderStep();
|
| 820 |
+
});
|
| 821 |
+
|
| 822 |
+
document.getElementById('btnPlay').addEventListener('click', () => {
|
| 823 |
+
playing = !playing;
|
| 824 |
+
const btn = document.getElementById('btnPlay');
|
| 825 |
+
if (playing) {
|
| 826 |
+
btn.textContent = '⏸ Pause';
|
| 827 |
+
btn.classList.add('active');
|
| 828 |
+
playInterval = setInterval(() => {
|
| 829 |
+
if (currentStep < currentScenario.total_steps - 1) {
|
| 830 |
+
currentStep++;
|
| 831 |
+
renderStep();
|
| 832 |
+
} else {
|
| 833 |
+
playing = false;
|
| 834 |
+
btn.textContent = '▶ Play';
|
| 835 |
+
btn.classList.remove('active');
|
| 836 |
+
clearInterval(playInterval);
|
| 837 |
+
}
|
| 838 |
+
}, 1200 / speed);
|
| 839 |
+
} else {
|
| 840 |
+
btn.textContent = '▶ Play';
|
| 841 |
+
btn.classList.remove('active');
|
| 842 |
+
clearInterval(playInterval);
|
| 843 |
+
}
|
| 844 |
+
});
|
| 845 |
+
|
| 846 |
+
document.getElementById('btnSpeed').addEventListener('click', () => {
|
| 847 |
+
const idx = speeds.indexOf(speed);
|
| 848 |
+
speed = speeds[(idx + 1) % speeds.length];
|
| 849 |
+
document.getElementById('btnSpeed').textContent = `${speed}x`;
|
| 850 |
+
if (playing) {
|
| 851 |
+
clearInterval(playInterval);
|
| 852 |
+
document.getElementById('btnPlay').click();
|
| 853 |
+
document.getElementById('btnPlay').click();
|
| 854 |
+
}
|
| 855 |
+
});
|
| 856 |
+
|
| 857 |
+
// Keyboard shortcuts
|
| 858 |
+
document.addEventListener('keydown', (e) => {
|
| 859 |
+
if (e.key === 'ArrowLeft') { document.getElementById('btnPrev').click(); }
|
| 860 |
+
if (e.key === 'ArrowRight') { document.getElementById('btnNext').click(); }
|
| 861 |
+
if (e.key === ' ') { e.preventDefault(); document.getElementById('btnPlay').click(); }
|
| 862 |
+
});
|
| 863 |
+
|
| 864 |
+
// ============================================================
|
| 865 |
+
// Init — populate scenario selector and load first
|
| 866 |
+
// ============================================================
|
| 867 |
+
async function init() {
|
| 868 |
+
const select = document.getElementById('scenarioSelect');
|
| 869 |
+
const scenarioNames = ['tcache_poison', 'fastbin_dup'];
|
| 870 |
+
const scenarioTitles = { tcache_poison: 'Tcache Poisoning', fastbin_dup: 'Fastbin Duplication' };
|
| 871 |
+
|
| 872 |
+
scenarioNames.forEach(name => {
|
| 873 |
+
const opt = document.createElement('option');
|
| 874 |
+
opt.value = name;
|
| 875 |
+
opt.textContent = scenarioTitles[name] || name;
|
| 876 |
+
select.appendChild(opt);
|
| 877 |
+
});
|
| 878 |
+
|
| 879 |
+
select.addEventListener('change', () => loadScenario(select.value));
|
| 880 |
+
await loadScenario(scenarioNames[0]);
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
init();
|
| 884 |
+
</script>
|
| 885 |
+
</body>
|
| 886 |
+
</html>
|