ropnroll 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ropnroll-0.1.0/PKG-INFO +12 -0
- ropnroll-0.1.0/README.md +274 -0
- ropnroll-0.1.0/pyproject.toml +30 -0
- ropnroll-0.1.0/ropnroll/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/cli/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/cli/main.py +356 -0
- ropnroll-0.1.0/ropnroll/core/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/core/archinfo.py +145 -0
- ropnroll-0.1.0/ropnroll/core/gadget.py +41 -0
- ropnroll-0.1.0/ropnroll/core/loader.py +316 -0
- ropnroll-0.1.0/ropnroll/core/output.py +42 -0
- ropnroll-0.1.0/ropnroll/core/scanner.py +349 -0
- ropnroll-0.1.0/ropnroll/core/security.py +52 -0
- ropnroll-0.1.0/ropnroll/libcdb.py +54 -0
- ropnroll-0.1.0/ropnroll/orchestrate.py +62 -0
- ropnroll-0.1.0/ropnroll/semantics/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/semantics/effect.py +143 -0
- ropnroll-0.1.0/ropnroll/semantics/engine.py +489 -0
- ropnroll-0.1.0/ropnroll/semantics/query.py +108 -0
- ropnroll-0.1.0/ropnroll/solve/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/solve/callchain.py +90 -0
- ropnroll-0.1.0/ropnroll/solve/chain.py +317 -0
- ropnroll-0.1.0/ropnroll/solve/jop.py +91 -0
- ropnroll-0.1.0/ropnroll/solve/onegadget.py +207 -0
- ropnroll-0.1.0/ropnroll/solve/pivot.py +52 -0
- ropnroll-0.1.0/ropnroll/solve/pool.py +82 -0
- ropnroll-0.1.0/ropnroll/solve/srop.py +221 -0
- ropnroll-0.1.0/ropnroll/solve/syscallchain.py +65 -0
- ropnroll-0.1.0/ropnroll/verify/__init__.py +0 -0
- ropnroll-0.1.0/ropnroll/verify/emulate.py +140 -0
- ropnroll-0.1.0/ropnroll.egg-info/PKG-INFO +12 -0
- ropnroll-0.1.0/ropnroll.egg-info/SOURCES.txt +41 -0
- ropnroll-0.1.0/ropnroll.egg-info/dependency_links.txt +1 -0
- ropnroll-0.1.0/ropnroll.egg-info/entry_points.txt +2 -0
- ropnroll-0.1.0/ropnroll.egg-info/requires.txt +8 -0
- ropnroll-0.1.0/ropnroll.egg-info/top_level.txt +1 -0
- ropnroll-0.1.0/setup.cfg +4 -0
- ropnroll-0.1.0/tests/test_libcdb.py +18 -0
- ropnroll-0.1.0/tests/test_onegadget.py +12 -0
- ropnroll-0.1.0/tests/test_scanner.py +62 -0
- ropnroll-0.1.0/tests/test_semantics.py +67 -0
- ropnroll-0.1.0/tests/test_solver.py +141 -0
- ropnroll-0.1.0/tests/test_srop.py +30 -0
ropnroll-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ropnroll
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Semantic ROP/JOP gadget finder and automatic exploit-chain synthesizer
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: capstone>=5.0
|
|
7
|
+
Requires-Dist: lief>=0.14
|
|
8
|
+
Requires-Dist: unicorn>=2.0
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
12
|
+
Requires-Dist: keystone-engine>=0.9; extra == "dev"
|
ropnroll-0.1.0/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# ropnroll
|
|
2
|
+
|
|
3
|
+
A ROP/JOP gadget finder and **automatic exploit-chain synthesizer**. You give it
|
|
4
|
+
a binary (and optionally a library it links against); it gives you a verified,
|
|
5
|
+
ready-to-fire chain for "call this function with these arguments," "make this
|
|
6
|
+
syscall," "pop a shell via a single sigreturn frame," "pivot the stack," or a
|
|
7
|
+
raw semantic gadget search — grounded in real CPU emulation at every step, not
|
|
8
|
+
a modeled instruction set.
|
|
9
|
+
|
|
10
|
+
Built for the case in the brief: you already have an arbitrary read/write
|
|
11
|
+
primitive (or a straightforward stack overflow) and want the rest of exploit
|
|
12
|
+
construction — register setup, calling convention, ASLR-aware addressing,
|
|
13
|
+
chain correctness — handled for you. It's been fired for real against a live
|
|
14
|
+
process (see [Live-fire demo](#live-fire-demo)), not just checked against a
|
|
15
|
+
static binary.
|
|
16
|
+
|
|
17
|
+
## Support matrix
|
|
18
|
+
|
|
19
|
+
Honest, not aspirational — "tested" means an automated test in `tests/`
|
|
20
|
+
actually exercises it against a real binary, every run.
|
|
21
|
+
|
|
22
|
+
| | ELF (Linux) | PE (Windows) |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| **x86-64** | ✅ tested — scanner, solver, call/syscall/SROP chains, live-fire exploit, verifier | ✅ tested — loading, mitigations (incl. Control Flow Guard), scanning, MS x64 ABI (RCX/RDX/R8/R9 + shadow space) |
|
|
25
|
+
| **x86 (32-bit)** | 🟡 wired up, scanner-level tested; solver/chain builders share the same code path as x86-64 but aren't exercised by an automated test against a real 32-bit target | ✅ tested — loading, mitigations, scanning (cdecl/stdcall stack-arg calling convention) |
|
|
26
|
+
| **ARM32 / ARM64** | 🟡 scanner + semantic engine wired up (shares the Unicorn-measured-effects design with x86), not exercised by an automated test against a real ARM binary this session | — |
|
|
27
|
+
| **MIPS32 / MIPS64** | 🟡 scanner + semantic engine wired up, including branch-delay-slot handling; not exercised by an automated test this session | — |
|
|
28
|
+
| **RISC-V / PowerPC / Thumb** | ❌ not supported | — |
|
|
29
|
+
|
|
30
|
+
SROP is Linux x86-64 only (it's a Linux kernel ABI detail, not portable). The
|
|
31
|
+
one-gadget finder and libc.rip lookup are scoped to x86/x86-64. Everything in
|
|
32
|
+
the ✅ row has a corresponding test in `tests/` that runs against a real
|
|
33
|
+
binary — not a mock — every time the suite runs.
|
|
34
|
+
|
|
35
|
+
## Why another one of these
|
|
36
|
+
|
|
37
|
+
Market research before writing any code, plus a live head-to-head:
|
|
38
|
+
|
|
39
|
+
| Tool | Approach | Notable limitation ropnroll targets |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| **Ropper** / **ROPgadget** | Syntactic/regex gadget search | No semantics at all — you read disassembly and build chains by hand |
|
|
42
|
+
| **angrop** (angr) | Symbolic execution via claripy | Powerful but slow (minutes on libc-sized targets per public benchmarks), no standalone CLI |
|
|
43
|
+
| **ROPium** | Custom IR + semantic queries (`rax=rbx+8`) | Gadget-level chaining only; no concrete verification, no JOP synthesis |
|
|
44
|
+
| **Ropinator** | Z3-backed solver + "affine executor" fast path, TUI + MCP server | Closest competitor — see below |
|
|
45
|
+
| **wedp** | WinDbg plugin for interactive Windows exploit dev | Different category: live-debugger-assisted workflow, not offline gadget/chain synthesis |
|
|
46
|
+
| **one_gadget** | Curated, per-glibc-version static analysis of source | Needs a maintained version database; ropnroll's `onegadget` instead concretely emulates forward from shell-string references, no database, works on whatever you hand it |
|
|
47
|
+
| **libc-database** / pwntools `libcdb` | Local database you build and query | ropnroll's `libcid` hits libc.rip directly — no local DB to maintain |
|
|
48
|
+
|
|
49
|
+
**Ropinator specifically** (`pip install ropinator`) is the tool ropnroll has the
|
|
50
|
+
most in common with, so I installed it and ran it head-to-head rather than
|
|
51
|
+
guessing. On `libc.so.6` at matched depth: Ropinator found 63,964 gadgets in
|
|
52
|
+
20s wall-clock (using ~2m39s of CPU across threads); ropnroll found 34,235 in
|
|
53
|
+
15.9s single-threaded, pure Python. The gap isn't random — **13,971 (~22%) of
|
|
54
|
+
Ropinator's results contain a mid-body conditional jump** (`je`, `jne`, ...),
|
|
55
|
+
which ropnroll's scanner deliberately excludes: a gadget whose body can branch
|
|
56
|
+
away before reaching its own terminator isn't a gadget you can rely on to do
|
|
57
|
+
what its disassembly suggests. That's a real trade — fewer total matches for a
|
|
58
|
+
hard reliability guarantee on every one that's returned.
|
|
59
|
+
|
|
60
|
+
Where ropnroll tries to actually add something new, not just re-implement:
|
|
61
|
+
|
|
62
|
+
1. **Semantics measured by real emulation, not modeled.** Every gadget's
|
|
63
|
+
effect (which register gets what value, which memory gets written) is
|
|
64
|
+
determined by actually running it in Unicorn across several input vectors
|
|
65
|
+
and fitting the relation — correct for whatever the silicon does,
|
|
66
|
+
automatically, with no per-opcode IR to write or get wrong.
|
|
67
|
+
2. **Concrete chain verification.** Once a chain is built, ropnroll re-emulates
|
|
68
|
+
the *whole thing* against the real target's mapped memory and reports
|
|
69
|
+
pass/fail per goal register, plus exactly which gadget faulted. This isn't
|
|
70
|
+
a nicety — building this tool, the verifier (and a live-fire test against
|
|
71
|
+
a real process) caught five real bugs in the solver, none of which would
|
|
72
|
+
have been caught by inspection. See "How this was validated."
|
|
73
|
+
3. **A register/call/syscall solver that goes past one hop.** Backward-
|
|
74
|
+
chaining search with memoization and a work budget, not just "pop-style
|
|
75
|
+
gadget or bail" — finds multi-gadget indirection paths (`pop reg1` →
|
|
76
|
+
`mov reg2, reg1` → `mov target, reg2`) when no direct popper exists, and
|
|
77
|
+
explicitly rejects any candidate that turns out to be a disguised stack
|
|
78
|
+
pivot or that touches memory through an uncontrolled register.
|
|
79
|
+
4. **SROP as a first-class goal**, not an afterthought: one sigreturn frame
|
|
80
|
+
can set *every* register in a single step, including chaining straight
|
|
81
|
+
into execve with zero conventional gadgets beyond a single `pop rax`.
|
|
82
|
+
5. **JOP is actually synthesized, not just tagged.** `ropnroll jop` finds
|
|
83
|
+
*dispatcher* gadgets in the Bletsch et al. sense — an indirect jump/call
|
|
84
|
+
through a register that the same gadget also advances by a constant — by
|
|
85
|
+
asking the semantic engine directly, and can build the function-pointer
|
|
86
|
+
table that turns one into an arbitrary-length JOP chain.
|
|
87
|
+
6. **CET/CFG-aware by construction.** Under Intel CET-IBT or Windows Control
|
|
88
|
+
Flow Guard, an indirect branch may only land on an approved entry point.
|
|
89
|
+
The JOP dispatcher builder checks a PE's real Guard CF function table
|
|
90
|
+
(read via LIEF) instead of silently producing a chain that dies on the
|
|
91
|
+
first hop on a hardened target.
|
|
92
|
+
7. **Empirical one-gadget discovery.** No curated glibc-version database:
|
|
93
|
+
finds every place the binary's own code loads a shell-string pointer, and
|
|
94
|
+
concretely emulates forward (with syscalls it can't service — `clone`,
|
|
95
|
+
`mmap` — faked plausibly so real code paths like modern glibc's
|
|
96
|
+
`posix_spawn`-based `system()` can be walked) to confirm a real execve
|
|
97
|
+
fires. A hit is observed, not guessed.
|
|
98
|
+
8. **libc identification** against libc.rip from as few as 2–3 leaked symbol
|
|
99
|
+
offsets, with the exact build's full symbol table and a download link, in
|
|
100
|
+
one call.
|
|
101
|
+
9. **Live-target orchestration.** Attach to a running process via
|
|
102
|
+
`/proc/<pid>/maps` (no ptrace, no special privileges), rebase gadget pools
|
|
103
|
+
to the *actual* runtime addresses, and hand back an `Image` the rest of
|
|
104
|
+
the tool uses exactly like a static file. `examples/live_fire_demo.py`
|
|
105
|
+
uses this to build a chain and pop a real shell in a real subprocess.
|
|
106
|
+
|
|
107
|
+
What I'm *not* claiming to beat Ropinator on: it already ships a TUI and an
|
|
108
|
+
MCP server for agent-native use, and covers RISC-V/PowerPC/Thumb, which this
|
|
109
|
+
project doesn't attempt (see the support matrix above — a deliberate scope
|
|
110
|
+
decision, not an oversight). It's also a shipped, presumably battle-tested
|
|
111
|
+
tool; this was built and tested in one sitting against real binaries and a
|
|
112
|
+
real live process, not years of field use.
|
|
113
|
+
|
|
114
|
+
## Install
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# pip
|
|
118
|
+
pip install -e .
|
|
119
|
+
|
|
120
|
+
# uv (fast, and what this repo's CI/dev workflow assumes)
|
|
121
|
+
uv pip install -e .
|
|
122
|
+
# or, without an explicit venv step:
|
|
123
|
+
uv sync # if you're working from a clone and want a project-local .venv
|
|
124
|
+
|
|
125
|
+
# uvx: run the CLI directly without a persistent install
|
|
126
|
+
uvx --from git+https://github.com/jordanallred/ropnroll ropnroll security ./target
|
|
127
|
+
|
|
128
|
+
# once a release is published to PyPI (tag a v* release to trigger it):
|
|
129
|
+
uvx ropnroll security ./target
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Needs `capstone`, `lief`, `unicorn`, `rich` — all pulled in automatically.
|
|
133
|
+
`libcid` uses the standard library's `urllib` (no extra dependency).
|
|
134
|
+
|
|
135
|
+
## Usage
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# security/mitigation report (NX, PIE, RELRO, canary, CFG for PE...)
|
|
139
|
+
ropnroll security ./target
|
|
140
|
+
|
|
141
|
+
# raw gadget listing (like Ropper/ROPgadget, but conditional-jump-free by default)
|
|
142
|
+
ropnroll scan ./target --regex "pop r.i"
|
|
143
|
+
|
|
144
|
+
# semantic search, ROPium-style DSL
|
|
145
|
+
ropnroll search ./target ./libc.so.6 --query "rdi=rax+8"
|
|
146
|
+
|
|
147
|
+
# stack pivots / JOP dispatcher gadgets
|
|
148
|
+
ropnroll pivot ./target
|
|
149
|
+
ropnroll jop ./libc.so.6
|
|
150
|
+
|
|
151
|
+
# build + verify a ret2libc call, emit a pwntools-ready payload
|
|
152
|
+
ropnroll call ./target ./libc.so.6 --target system --args 0x1cc42f \
|
|
153
|
+
--verify --emit pwntools
|
|
154
|
+
|
|
155
|
+
# build + verify a direct execve("/bin/sh", NULL, NULL) syscall chain
|
|
156
|
+
ropnroll syscall ./libc.so.6 --nr 59 --args 0x1cc42f,0,0 --verify --emit pwntools
|
|
157
|
+
|
|
158
|
+
# a sigreturn frame that pops a shell with a single gadget, period
|
|
159
|
+
ropnroll srop ./libc.so.6 --args 0x1cc42f,0,0 --verify --emit pwntools
|
|
160
|
+
|
|
161
|
+
# empirically confirmed one-gadgets (no curated database)
|
|
162
|
+
ropnroll onegadget ./libc.so.6
|
|
163
|
+
|
|
164
|
+
# identify a libc build from a couple of leaked symbol offsets
|
|
165
|
+
ropnroll libcid --symbol system=0x58750 --symbol read=0x11bd20 --download /tmp/libc.so.6
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`call`, `syscall`, and `srop` accept multiple binaries (target + libc) and
|
|
169
|
+
pool their gadgets together, same as a real exploit would pull from both.
|
|
170
|
+
|
|
171
|
+
## Live-fire demo
|
|
172
|
+
|
|
173
|
+
`examples/live_fire_demo.py` doesn't just build a chain — it spawns the real
|
|
174
|
+
vulnerable test binary, attaches to it, reads its *actual* runtime memory map
|
|
175
|
+
(standing in for whatever leak primitive gets you the same information
|
|
176
|
+
against a real target), rebases ropnroll's gadget pool to those live
|
|
177
|
+
addresses, builds a ret2libc chain with the solver, delivers it through the
|
|
178
|
+
real stack overflow, and confirms a real shell by running a command in it and
|
|
179
|
+
reading the output back:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
[*] live binary base : 0x555555554000
|
|
183
|
+
[*] live libc base : 0x7ffff7c00000
|
|
184
|
+
[*] building the call chain with ropnroll's own solver (not hand-picked)...
|
|
185
|
+
pop-style: pop rdi ; ret @ 0x7ffff7d0c08d sets ['rdi']
|
|
186
|
+
[*] payload is 104 bytes (72 junk + 32 chain)
|
|
187
|
+
[+] SHELL CONFIRMED -- got real code execution via a chain ropnroll built itself
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
(This sandbox has ASLR disabled system-wide — no root available to change it
|
|
191
|
+
— so the "defeated" addresses happen to match the static file. The code path
|
|
192
|
+
reads live addresses from the live process either way, so the mechanism is
|
|
193
|
+
real; there just isn't a randomized target to prove it against here.)
|
|
194
|
+
|
|
195
|
+
## How this was validated
|
|
196
|
+
|
|
197
|
+
Nothing here is asserted without having actually run it — against real
|
|
198
|
+
binaries, a real libc, and in one case a real running process:
|
|
199
|
+
|
|
200
|
+
- Scanner + semantic engine run against `/lib/x86_64-linux-gnu/libc.so.6` (a
|
|
201
|
+
real, unmodified system libc), freshly-`gcc`-compiled x86-64 test binaries,
|
|
202
|
+
and real 32-bit/64-bit Windows PE executables — loading, mitigation
|
|
203
|
+
detection (including Control Flow Guard), and gadget scanning all
|
|
204
|
+
confirmed.
|
|
205
|
+
- The register solver, call/syscall/SROP chain builders were used to build
|
|
206
|
+
an actual `system("/bin/sh")` ret2libc chain, a direct `execve` syscall
|
|
207
|
+
chain, and a single-gadget SROP execve chain — sourced *entirely* from
|
|
208
|
+
libc's own gadgets, no hand-picked addresses — then concretely
|
|
209
|
+
re-emulated against libc's real mapped memory via `--verify`. All pass.
|
|
210
|
+
- The one-gadget finder found and concretely confirmed a real one-gadget in
|
|
211
|
+
system libc by emulating through actual modern glibc `posix_spawn`
|
|
212
|
+
internals (not a toy code path).
|
|
213
|
+
- The libc.rip lookup was checked against a real leak of this exact libc's
|
|
214
|
+
`system`/`read`/`printf` offsets and correctly identified the exact build,
|
|
215
|
+
including the `/bin/sh` string offset matching what the scanner found
|
|
216
|
+
independently.
|
|
217
|
+
- `examples/live_fire_demo.py` spawns the real vulnerable binary, attaches
|
|
218
|
+
via `/proc/<pid>/maps`, and pops a real shell with a chain the solver
|
|
219
|
+
built itself — the strongest verification available short of a randomized
|
|
220
|
+
remote target.
|
|
221
|
+
- Along the way, **five real bugs were found and fixed by this same testing
|
|
222
|
+
loop, not by inspection**: a terminator-classification bug that let
|
|
223
|
+
byte-overlap coincidences masquerade as valid gadgets; a `leave`-based
|
|
224
|
+
pivot gadget getting silently used as a plain register setter; a
|
|
225
|
+
chain-assembly gap that left an unfilled word between gadgets; a solver
|
|
226
|
+
combinatorial-blowup bug once stricter safety checks narrowed the search
|
|
227
|
+
(fixed with memoization + a work budget); and a ret2libc stack-alignment
|
|
228
|
+
gotcha (entering a function via `ret` instead of `call` needs the same
|
|
229
|
+
rsp%16==8 residue, or its own callees crash on the first SSE instruction)
|
|
230
|
+
that the live-fire demo caught by actually crashing until it was fixed.
|
|
231
|
+
|
|
232
|
+
## Design notes
|
|
233
|
+
|
|
234
|
+
- **Scanner** (`core/scanner.py`): classic backward-window technique for x86
|
|
235
|
+
(misaligned/"unintended" gadgets included, like Ropper/ROPgadget), fixed-
|
|
236
|
+
instruction-width backward stepping for ARM/ARM64/MIPS (including MIPS
|
|
237
|
+
branch-delay-slot handling). Deliberately excludes conditional branches,
|
|
238
|
+
privileged instructions, and segment-register writes from gadget bodies —
|
|
239
|
+
each found because a gadget slipping through actually broke something
|
|
240
|
+
downstream, not preemptively.
|
|
241
|
+
- **Semantic engine** (`semantics/engine.py`): per-gadget effects are inferred
|
|
242
|
+
by running the gadget in Unicorn with several trial register values (basis
|
|
243
|
+
includes 0, all-ones, and a few patterns to catch AND/OR/XOR/scale
|
|
244
|
+
relations, not just copy/add) and fitting a closed-form relation to each
|
|
245
|
+
output register and memory write. Results are exact, not guessed — a
|
|
246
|
+
relation is only accepted if it holds across every trial.
|
|
247
|
+
- **Solver** (`solve/chain.py`): greedy multi-cover search over "pop"-style
|
|
248
|
+
(load-from-stack) gadgets first; falls back to depth-bounded, memoized
|
|
249
|
+
backward-chaining search for register-to-register indirection when no
|
|
250
|
+
direct popper exists. Rejects any candidate that's a disguised stack pivot
|
|
251
|
+
or touches memory through a register nothing set.
|
|
252
|
+
- **Windows ABI awareness**: x86-64 PE targets automatically get the
|
|
253
|
+
Microsoft calling convention (RCX/RDX/R8/R9 + 32 bytes of shadow space),
|
|
254
|
+
not SysV's RDI/RSI/RDX/RCX/R8/R9 — picked from the binary's own format.
|
|
255
|
+
- **Stack-alignment correction**: `build_call` can take `bytes_before_chain`
|
|
256
|
+
(how much padding precedes the chain in the final payload) and will insert
|
|
257
|
+
a single bare `ret` gadget automatically if needed to keep a directly-
|
|
258
|
+
entered function's incoming stack correctly aligned.
|
|
259
|
+
|
|
260
|
+
## Known limitations / roadmap (being upfront, not shipping silently)
|
|
261
|
+
|
|
262
|
+
- ARM/ARM64/MIPS share the same scanner/semantic-engine design as x86-64 and
|
|
263
|
+
should work the same way, but aren't exercised by an automated test
|
|
264
|
+
against a real binary this session — see the support matrix.
|
|
265
|
+
- RISC-V, PowerPC, and Thumb are explicitly out of scope (a deliberate call
|
|
266
|
+
to focus on the architectures people actually hit, not a gap to fill
|
|
267
|
+
later without reconsidering).
|
|
268
|
+
- Register-value solving is depth-bounded backward-chaining, not full
|
|
269
|
+
BFS/constraint propagation like angrop — handles multi-hop indirection but
|
|
270
|
+
isn't exhaustive.
|
|
271
|
+
- `onegadget`'s syscall-faking (for `clone`/`mmap`/etc.) is a pragmatic
|
|
272
|
+
stand-in for real kernel behavior, not a full OS emulation layer — it gets
|
|
273
|
+
far enough to find real gadgets through modern glibc, but an exotic code
|
|
274
|
+
path could still need a syscall this doesn't stub correctly.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ropnroll"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Semantic ROP/JOP gadget finder and automatic exploit-chain synthesizer"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"capstone>=5.0",
|
|
12
|
+
"lief>=0.14",
|
|
13
|
+
"unicorn>=2.0",
|
|
14
|
+
"rich>=13.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"keystone-engine>=0.9",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
ropnroll = "ropnroll.cli.main:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
include = ["ropnroll*"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .. import libcdb
|
|
11
|
+
from ..core import loader, output, scanner, security
|
|
12
|
+
from ..core.gadget import Terminator
|
|
13
|
+
from ..solve import callchain, chain as chainmod, jop, onegadget, pivot, srop, syscallchain
|
|
14
|
+
from ..solve.pool import GadgetPool
|
|
15
|
+
from ..semantics import query as querymod
|
|
16
|
+
from ..verify.emulate import verify_chain
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_pool(paths: list[str], scan_opts: scanner.ScanOptions) -> tuple[GadgetPool, list]:
|
|
22
|
+
pool = GadgetPool()
|
|
23
|
+
images = []
|
|
24
|
+
for p in paths:
|
|
25
|
+
img = loader.load(p)
|
|
26
|
+
gs = scanner.scan_image(img, scan_opts)
|
|
27
|
+
pool.add(img, gs)
|
|
28
|
+
images.append(img)
|
|
29
|
+
return pool, images
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scan_opts(args) -> scanner.ScanOptions:
|
|
33
|
+
bad = bytes.fromhex(args.bad_bytes) if getattr(args, "bad_bytes", None) else b""
|
|
34
|
+
return scanner.ScanOptions(
|
|
35
|
+
max_insns=args.max_insns, rop=not args.no_rop, jop=not args.no_jop,
|
|
36
|
+
sys=not args.no_sys, bad_bytes=bad,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_scan(args):
|
|
41
|
+
pool, images = _load_pool([args.binary], _scan_opts(args))
|
|
42
|
+
gadgets = pool.all()
|
|
43
|
+
if args.regex:
|
|
44
|
+
import re
|
|
45
|
+
rx = re.compile(args.regex)
|
|
46
|
+
gadgets = [g for g in gadgets if rx.search(g.text)]
|
|
47
|
+
gadgets.sort(key=lambda g: g.address)
|
|
48
|
+
if args.out:
|
|
49
|
+
with open(args.out, "w") as f:
|
|
50
|
+
for g in gadgets:
|
|
51
|
+
f.write(f"0x{g.address:016x} : {g.text}\n")
|
|
52
|
+
console.print(f"[green]wrote {len(gadgets)} gadgets to {args.out}[/green]")
|
|
53
|
+
return
|
|
54
|
+
for g in gadgets[: args.limit]:
|
|
55
|
+
console.print(f"0x{g.address:016x} : {escape(g.text)}")
|
|
56
|
+
console.print(f"[bold]{len(gadgets)}[/bold] gadgets total"
|
|
57
|
+
+ (f" (showing first {args.limit})" if len(gadgets) > args.limit else ""))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_security(args):
|
|
61
|
+
img = loader.load(args.binary)
|
|
62
|
+
opts = _scan_opts(args)
|
|
63
|
+
gs = scanner.scan_image(img, opts)
|
|
64
|
+
report = security.build_report(img, gs)
|
|
65
|
+
t = Table(title=f"security report: {args.binary}")
|
|
66
|
+
t.add_column("property")
|
|
67
|
+
t.add_column("value")
|
|
68
|
+
for k, v in report.lines:
|
|
69
|
+
t.add_row(k, v)
|
|
70
|
+
console.print(t)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_search(args):
|
|
74
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
75
|
+
try:
|
|
76
|
+
results = querymod.search(pool, args.query, limit=args.limit)
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
console.print(f"[red]{e}[/red]")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
if not results:
|
|
81
|
+
console.print("[yellow]no gadgets matched[/yellow]")
|
|
82
|
+
return
|
|
83
|
+
for g, eff, field in results:
|
|
84
|
+
console.print(f"0x{g.address:016x} : {escape(g.text)} [dim]sp_delta={eff.sp_delta}[/dim]")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_pivot(args):
|
|
88
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
89
|
+
pivots = pivot.find_pivots(pool)
|
|
90
|
+
t = Table(title="stack pivots")
|
|
91
|
+
t.add_column("address")
|
|
92
|
+
t.add_column("gadget")
|
|
93
|
+
t.add_column("kind")
|
|
94
|
+
for p in pivots[: args.limit]:
|
|
95
|
+
t.add_row(f"0x{p.gadget.address:x}", escape(p.gadget.text), p.kind)
|
|
96
|
+
console.print(t)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_jop(args):
|
|
100
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
101
|
+
disp = jop.find_dispatchers(pool, img=images[0])
|
|
102
|
+
t = Table(title="JOP dispatcher gadgets (self-advancing jmp/call-through-register)")
|
|
103
|
+
t.add_column("address")
|
|
104
|
+
t.add_column("gadget")
|
|
105
|
+
t.add_column("reg")
|
|
106
|
+
t.add_column("advance")
|
|
107
|
+
for d in disp[: args.limit]:
|
|
108
|
+
t.add_row(f"0x{d.gadget.address:x}", escape(d.gadget.text), d.reg, f"0x{d.advance:x}")
|
|
109
|
+
console.print(t)
|
|
110
|
+
console.print(f"[bold]{len(disp)}[/bold] dispatcher(s) found")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_onegadget(args):
|
|
114
|
+
img = loader.load(args.binary)
|
|
115
|
+
if img.arch not in ("x86_64", "x86"):
|
|
116
|
+
console.print(f"[red]one-gadget search is scoped to x86/x86-64 (target is {img.arch})[/red]")
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
console.print("[dim]searching for shell-string references and emulating forward "
|
|
119
|
+
"from each one -- this concretely runs the real code, so it can take "
|
|
120
|
+
"a few seconds...[/dim]")
|
|
121
|
+
rep = onegadget.find_one_gadgets(img, max_insns=args.max_insns)
|
|
122
|
+
t = Table(title="one-gadgets (empirically confirmed via emulation, not a curated database)")
|
|
123
|
+
t.add_column("address")
|
|
124
|
+
t.add_column("string")
|
|
125
|
+
t.add_column("starting state that worked")
|
|
126
|
+
t.add_column("instructions run")
|
|
127
|
+
for c in rep.candidates:
|
|
128
|
+
t.add_row(f"0x{c.address:x}", c.string.decode(), c.constraint, str(c.instructions_run))
|
|
129
|
+
console.print(t)
|
|
130
|
+
console.print(f"searched {rep.searched_sites} shell-string reference site(s), "
|
|
131
|
+
f"[bold]{len(rep.candidates)}[/bold] confirmed")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_libcid(args):
|
|
135
|
+
offsets = {}
|
|
136
|
+
for item in args.symbol:
|
|
137
|
+
name, _, off = item.partition("=")
|
|
138
|
+
if not off:
|
|
139
|
+
console.print(f"[red]expected name=0xoffset, got {item!r}[/red]")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
offsets[name] = int(off, 0)
|
|
142
|
+
try:
|
|
143
|
+
matches = libcdb.identify(offsets)
|
|
144
|
+
except ConnectionError as e:
|
|
145
|
+
console.print(f"[red]{e}[/red]")
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
if not matches:
|
|
148
|
+
console.print("[yellow]no matching libc build found[/yellow]")
|
|
149
|
+
return
|
|
150
|
+
for m in matches:
|
|
151
|
+
console.print(f"[bold]{m.id}[/bold] buildid={m.buildid}")
|
|
152
|
+
console.print(f" download: {m.download_url}")
|
|
153
|
+
for k, v in sorted(m.symbols.items()):
|
|
154
|
+
console.print(f" {k} = 0x{v:x}")
|
|
155
|
+
if args.download:
|
|
156
|
+
if len(matches) > 1:
|
|
157
|
+
console.print(f"[yellow]{len(matches)} matches -- downloading the first ({matches[0].id})[/yellow]")
|
|
158
|
+
path = libcdb.download(matches[0], args.download)
|
|
159
|
+
console.print(f"[green]saved to {path}[/green]")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_srop(args):
|
|
163
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
164
|
+
if pool.ai.arch != "x86_64":
|
|
165
|
+
console.print("[red]SROP support here is scoped to Linux x86-64[/red]")
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
args_list = [int(a, 0) for a in args.args.split(",")] if args.args else [0, 0, 0]
|
|
168
|
+
while len(args_list) < 3:
|
|
169
|
+
args_list.append(0)
|
|
170
|
+
res = srop.build_srop_execve(pool, path_ptr=args_list[0], argv_ptr=args_list[1], envp_ptr=args_list[2])
|
|
171
|
+
for l in res.solve.log:
|
|
172
|
+
console.print(f"[dim]{l}[/dim]")
|
|
173
|
+
if not res.ok:
|
|
174
|
+
console.print(f"[red]could not build SROP chain: {res.solve.unresolved}[/red]")
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
console.print(output.stack_layout(res.chain), markup=False)
|
|
177
|
+
expect = {"rax": 59, "rdi": args_list[0], "rsi": args_list[1], "rdx": args_list[2]}
|
|
178
|
+
consistent = srop.verify_srop_frame(res.chain, res.frame_offset_words, expect)
|
|
179
|
+
console.print(f"\nframe self-consistency check: {'[green]OK[/green]' if consistent else '[red]FAIL[/red]'}")
|
|
180
|
+
if args.verify:
|
|
181
|
+
ok, result = srop.emulate_srop_chain(images[0], res.chain, expect)
|
|
182
|
+
console.print(f"emulated (simulated sigreturn semantics -- see srop.py docstring): "
|
|
183
|
+
f"{'[green]OK[/green]' if ok else '[red]FAIL[/red]'}")
|
|
184
|
+
if args.emit:
|
|
185
|
+
_emit(res.chain, args.emit, args.out)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _emit(chain, fmt: str, out: str | None):
|
|
189
|
+
if fmt == "pwntools":
|
|
190
|
+
text = output.to_pwntools(chain)
|
|
191
|
+
elif fmt == "c":
|
|
192
|
+
text = output.to_c_array(chain)
|
|
193
|
+
elif fmt == "json":
|
|
194
|
+
text = output.to_json(chain)
|
|
195
|
+
elif fmt == "raw":
|
|
196
|
+
data = output.to_raw(chain)
|
|
197
|
+
if out:
|
|
198
|
+
with open(out, "wb") as f:
|
|
199
|
+
f.write(data)
|
|
200
|
+
console.print(f"[green]wrote {len(data)} raw bytes to {out}[/green]")
|
|
201
|
+
else:
|
|
202
|
+
sys.stdout.buffer.write(data)
|
|
203
|
+
return
|
|
204
|
+
else:
|
|
205
|
+
raise ValueError(fmt)
|
|
206
|
+
if out:
|
|
207
|
+
with open(out, "w") as f:
|
|
208
|
+
f.write(text)
|
|
209
|
+
console.print(f"[green]wrote to {out}[/green]")
|
|
210
|
+
else:
|
|
211
|
+
console.print(text, markup=False)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _do_verify(images, chain, final_target, goal_regs):
|
|
215
|
+
rep = verify_chain(images[0], chain, final_target=final_target, goal_regs=goal_regs)
|
|
216
|
+
console.print(f"\n[bold]-- concrete verification (Unicorn) --[/bold]")
|
|
217
|
+
console.print(f"reached target: {rep.reached_target} fault: {rep.fault} "
|
|
218
|
+
f"insns executed: {rep.instructions_executed}")
|
|
219
|
+
for reg, (ok, want, got) in rep.goal_results.items():
|
|
220
|
+
mark = "[green]OK[/green]" if ok else "[red]MISMATCH[/red]"
|
|
221
|
+
console.print(f" {reg}: expected 0x{want:x}, got "
|
|
222
|
+
f"{'0x%x' % got if got is not None else '?'} {mark}")
|
|
223
|
+
if rep.fault:
|
|
224
|
+
console.print(f"[red]fault at 0x{rep.fault_address:x} near: {rep.last_gadget_context}[/red]")
|
|
225
|
+
console.print(f"[bold]{'PASS' if rep.ok else 'FAIL'}[/bold]")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_call(args):
|
|
229
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
230
|
+
target = int(args.target, 0) if args.target.startswith("0x") or args.target.isdigit() else None
|
|
231
|
+
if target is None:
|
|
232
|
+
for img in images:
|
|
233
|
+
if args.target in img.symbols:
|
|
234
|
+
target = img.symbols[args.target]
|
|
235
|
+
break
|
|
236
|
+
if target is None:
|
|
237
|
+
console.print(f"[red]symbol {args.target!r} not found[/red]")
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
args_list = [int(a, 0) for a in args.args.split(",")] if args.args else []
|
|
240
|
+
res = callchain.build_call(pool, target, args_list)
|
|
241
|
+
for l in res.solve.log:
|
|
242
|
+
console.print(f"[dim]{l}[/dim]")
|
|
243
|
+
if not res.ok:
|
|
244
|
+
console.print(f"[red]could not build call chain; unresolved: {res.solve.unresolved}[/red]")
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
console.print(output.stack_layout(res.chain), markup=False)
|
|
247
|
+
if args.emit:
|
|
248
|
+
_emit(res.chain, args.emit, args.out)
|
|
249
|
+
if args.verify:
|
|
250
|
+
goal = {r: v for r, v in zip(pool.ai.call_arg_regs, args_list)}
|
|
251
|
+
_do_verify(images, res.chain, target, goal)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_syscall(args):
|
|
255
|
+
pool, images = _load_pool(args.binary, _scan_opts(args))
|
|
256
|
+
args_list = [int(a, 0) for a in args.args.split(",")] if args.args else []
|
|
257
|
+
res = syscallchain.build_syscall(pool, args.nr, args_list)
|
|
258
|
+
for l in res.solve.log:
|
|
259
|
+
console.print(f"[dim]{l}[/dim]")
|
|
260
|
+
if not res.ok:
|
|
261
|
+
console.print(f"[red]could not build syscall chain; unresolved: {res.solve.unresolved}[/red]")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
console.print(output.stack_layout(res.chain), markup=False)
|
|
264
|
+
if args.emit:
|
|
265
|
+
_emit(res.chain, args.emit, args.out)
|
|
266
|
+
if args.verify:
|
|
267
|
+
_do_verify(images, res.chain, res.gadget_addr, {})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
271
|
+
p = argparse.ArgumentParser(prog="ropnroll", description="ROP/JOP gadget finder and chain synthesizer")
|
|
272
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
273
|
+
common.add_argument("--max-insns", type=int, default=6)
|
|
274
|
+
common.add_argument("--no-rop", action="store_true")
|
|
275
|
+
common.add_argument("--no-jop", action="store_true")
|
|
276
|
+
common.add_argument("--no-sys", action="store_true")
|
|
277
|
+
common.add_argument("--bad-bytes", help="hex string, e.g. 000a0d")
|
|
278
|
+
|
|
279
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
280
|
+
|
|
281
|
+
s = sub.add_parser("scan", parents=[common])
|
|
282
|
+
s.add_argument("binary")
|
|
283
|
+
s.add_argument("--regex")
|
|
284
|
+
s.add_argument("--limit", type=int, default=50)
|
|
285
|
+
s.add_argument("--out")
|
|
286
|
+
s.set_defaults(func=cmd_scan)
|
|
287
|
+
|
|
288
|
+
s = sub.add_parser("security", parents=[common])
|
|
289
|
+
s.add_argument("binary")
|
|
290
|
+
s.set_defaults(func=cmd_security)
|
|
291
|
+
|
|
292
|
+
s = sub.add_parser("search", parents=[common])
|
|
293
|
+
s.add_argument("binary", nargs="+")
|
|
294
|
+
s.add_argument("--query", required=True, help='e.g. "rdi=rax+8", "[rbx]=rax", "rax=0"')
|
|
295
|
+
s.add_argument("--limit", type=int, default=20)
|
|
296
|
+
s.set_defaults(func=cmd_search)
|
|
297
|
+
|
|
298
|
+
s = sub.add_parser("pivot", parents=[common])
|
|
299
|
+
s.add_argument("binary", nargs="+")
|
|
300
|
+
s.add_argument("--limit", type=int, default=30)
|
|
301
|
+
s.set_defaults(func=cmd_pivot)
|
|
302
|
+
|
|
303
|
+
s = sub.add_parser("jop", parents=[common])
|
|
304
|
+
s.add_argument("binary", nargs="+")
|
|
305
|
+
s.add_argument("--limit", type=int, default=30)
|
|
306
|
+
s.set_defaults(func=cmd_jop)
|
|
307
|
+
|
|
308
|
+
s = sub.add_parser("onegadget")
|
|
309
|
+
s.add_argument("binary")
|
|
310
|
+
s.add_argument("--max-insns", type=int, default=2000,
|
|
311
|
+
help="instruction budget per candidate site during emulation")
|
|
312
|
+
s.set_defaults(func=cmd_onegadget)
|
|
313
|
+
|
|
314
|
+
s = sub.add_parser("srop", parents=[common], help="build a sigreturn-oriented execve chain")
|
|
315
|
+
s.add_argument("binary", nargs="+")
|
|
316
|
+
s.add_argument("--args", default="", help="path_ptr,argv_ptr,envp_ptr (comma-separated)")
|
|
317
|
+
s.add_argument("--emit", choices=["pwntools", "raw", "c", "json"])
|
|
318
|
+
s.add_argument("--out")
|
|
319
|
+
s.add_argument("--verify", action="store_true")
|
|
320
|
+
s.set_defaults(func=cmd_srop)
|
|
321
|
+
|
|
322
|
+
s = sub.add_parser("libcid", help="identify a libc build from leaked symbol offsets via libc.rip")
|
|
323
|
+
s.add_argument("--symbol", action="append", required=True,
|
|
324
|
+
help="name=0xoffset (offset from the leaked libc's own base), repeatable")
|
|
325
|
+
s.add_argument("--download", metavar="PATH", help="save the (first) matched libc here")
|
|
326
|
+
s.set_defaults(func=cmd_libcid)
|
|
327
|
+
|
|
328
|
+
s = sub.add_parser("call", parents=[common])
|
|
329
|
+
s.add_argument("binary", nargs="+")
|
|
330
|
+
s.add_argument("--target", required=True, help="address (0x...) or symbol name")
|
|
331
|
+
s.add_argument("--args", default="", help="comma-separated integers, e.g. 0x1000,0,0")
|
|
332
|
+
s.add_argument("--emit", choices=["pwntools", "raw", "c", "json"])
|
|
333
|
+
s.add_argument("--out")
|
|
334
|
+
s.add_argument("--verify", action="store_true")
|
|
335
|
+
s.set_defaults(func=cmd_call)
|
|
336
|
+
|
|
337
|
+
s = sub.add_parser("syscall", parents=[common])
|
|
338
|
+
s.add_argument("binary", nargs="+")
|
|
339
|
+
s.add_argument("--nr", type=int, required=True)
|
|
340
|
+
s.add_argument("--args", default="")
|
|
341
|
+
s.add_argument("--emit", choices=["pwntools", "raw", "c", "json"])
|
|
342
|
+
s.add_argument("--out")
|
|
343
|
+
s.add_argument("--verify", action="store_true")
|
|
344
|
+
s.set_defaults(func=cmd_syscall)
|
|
345
|
+
|
|
346
|
+
return p
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main(argv=None):
|
|
350
|
+
parser = build_parser()
|
|
351
|
+
args = parser.parse_args(argv)
|
|
352
|
+
args.func(args)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
main()
|
|
File without changes
|