diff --git a/README.md b/README.md index 152aa4d..5f3bb65 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,29 @@ rop3 is a tool developed in [Python](https://www.python.org/downloads/) and it r ## Features -- **Multi-format, multi-arch**: analyzes ELF, PE and Mach-O binaries (x86 and x86-64). For fat/universal Mach-O binaries, `--arch` selects the slice to analyze. -- **Gadget search**: ROP, JOP and RETF gadgets, with controls for search depth (`--depth`), undeterministic gadgets (`--allow-undeterministic-gadgets`) and complex memory operands (`--allow-complex-memory-ops`). -- **Operations and ROP chains**: search for high-level operations (`--op`/`--dst`/`--src`) and build ROP chains from a ROPLang file (`--ropchain`, `--exhaustive`), including multi-step composite operations. +- **Multi-format, multi-arch**: analyzes ELF, PE and Mach-O binaries across **x86, x86-64, AArch64 (ARM64) and RISC-V (RV64, including the compressed RVC extension)**. Architecture is detected from the binary; for fat/universal Mach-O binaries, `--arch` selects the slice to analyze. +- **Gadget search**: ROP, JOP and RETF gadgets, with controls for search depth (`--depth`, architecture-specific by default), `ret `/`retf ` terminators (`--ret-imm`, off by default), undeterministic gadgets (`--allow-undeterministic-gadgets`) and complex memory operands (`--allow-complex-memory-ops`). +- **Framed gadget search** (`--frame`/`--no-frame`): on AArch64 and RISC-V, where the return address lives in a register, framed search (on by default) keeps only gadgets that restore it from the stack — the ones actually reachable in a ROP chain. It has no effect on x86, where `ret` already consumes the stack. +- **Operations and ROP chains**: search for high-level operations with `--op` and **positional, n-ary operands** (`--operands op1 op2 op3`), and build ROP chains from a ROPLang file (`--ropchain`, `--exhaustive`), including multi-step composite operations. `--keep-contradictory` disables the filtering of gadgets whose destination is overwritten before the terminator; `--reg-aliases` lets sub-registers (`al`, `ax`, `eax`) stand in for their full register. - **Relocation**: rebase any binary (ELF/PE/Mach-O) with `--base`, one address per binary. - **Bad-char filtering**: avoid bytes in the gadget address (`--badchar`) and/or in the gadget opcode bytes (`--badchar-bytes`). By default, duplicate gadgets prefer canary-free addresses (`0x00`, `0x0a`, `0x0d`, `0xff`); disable with `--keep-canary-address`. - **Symbol annotation**: with `--symbols`, each gadget is tagged with the nearest symbol (`name+offset`) when the binary is not stripped. -- **Output formats**: human-readable text (default), or machine-readable `--output json`/`--output csv` for scripting. Colors are emitted only on a TTY. +- **Output formats**: human-readable text (default), machine-readable `--output json`/`--output csv` for scripting, or `--tuple` for a compact `` line per gadget. Colors are emitted only on a TTY. With `--verbose`, rop3 also prints a per-binary summary (format, architecture, bit width, instruction alignment and executable sections). - **Interactive mode**: `--interactive` scans the binary once and drops into a REPL to explore gadgets, operations and chains without re-scanning. - **Performance**: parallel scanning across processes (`--jobs N`) and an optional on-disk gadget cache (`--cache`) for repeated runs over the same file. - **Library API**: use rop3 programmatically through the `Rop3` class (see [Use as a library](#use-as-a-library)). +## Supported architectures and formats + +| Architecture | ELF | PE | Mach-O | Notes | +| --- | :---: | :---: | :---: | --- | +| x86 (i386) | ✓ | ✓ | ✓ | | +| x86-64 | ✓ | ✓ | ✓ | | +| AArch64 | ✓ | ✓ | ✓ | 4-byte instruction alignment; framed search on by default | +| RISC-V (RV64)| ✓ | | | 4-byte alignment, or 2-byte with the compressed (RVC) extension; framed search on by default | + +Format is detected by magic bytes and the architecture from the binary's own headers. RISC-V is 64-bit only (RV32 is rejected). The high-level operations are defined per architecture, so some operations are unavailable on some targets — for example, the carry-flag operations (`eqc`, `ltc`, `gcf-eqc`, `gcf-ltc`) are not available on RISC-V, which has no condition/carry flags. + ## Installation We recommend to install rop3's dependencies with [pip](https://pypi.org/project/pip/) in a virtual environment to not to mess up with your current configuration: @@ -45,25 +57,48 @@ Now, you can install dependencies in [requirements.txt](requirements.txt): ## Usage +```Shell +$ python rop3.py --binary /bin/ls # dump gadgets +$ python rop3.py --binary /bin/ls --op mov --operands rdi rax +$ python rop3.py --binary libaarch64.so --op mov --operands x0 x1 +$ python rop3.py --binary libc.so.6 --ropchain chain.txt +$ python rop3.py --binary /bin/ls --interactive # REPL, scans once ``` -usage: rop3.py [-h] [-v] [--depth ] [--all] [--rop | --no-rop] [--retf | --no-retf] [--jop | --no-jop] [--allow-undeterministic-gadgets] [--allow-complex-memory-ops] [--verbose] - [--binary [ ...]] [--badchar [ ...]] [--badchar-bytes [ ...]] [--keep-canary-address] [--base [ ...]] [--arch ] [--symbols] - [--output {text,json,csv}] [--op ] [--dst ] [--src ] [--ropchain ] [--exhaustive | --no-exhaustive] [--interactive] [--jobs ] [--cache] [--cache-dir ] -This tool allows you to search for gadgets, operations, and ROP chains using a backtracking algorithm in a tree-like structure +``` +usage: rop3.py [-h] [-v] [--depth ] [--all] [--rop | --no-rop] + [--retf | --no-retf] [--ret-imm | --no-ret-imm] + [--jop | --no-jop] [--frame | --no-frame] [--reg-aliases] + [--allow-undeterministic-gadgets] [--allow-complex-memory-ops] + [--keep-contradictory] [--verbose] + [--binary [ ...]] [--badchar [ ...]] + [--badchar-bytes [ ...]] [--keep-canary-address] + [--base [ ...]] [--arch ] [--symbols] + [--output {text,json,csv}] [--tuple] [--op ] + [--operands [ ...]] [--ropchain ] + [--exhaustive | --no-exhaustive] [--interactive] [--jobs ] + [--cache] [--cache-dir ] + +This tool allows you to search for gadgets, operations, and ROP chains using a +backtracking algorithm in a tree-like structure options: -h, --help show this help message and exit -v, --version display rop3.py's version and exit - --depth depth for search engine (default to 5 bytes) + --depth maximum gadget length in bytes (default: architecture-specific) --all show the same gadget in different addresses --rop, --no-rop search for ROP gadgets --retf, --no-retf search for RETF gadgets + --ret-imm, --no-ret-imm + include gadgets ending in a `ret ` / `retf ` (disabled by default) --jop, --no-jop search for JOP gadgets + --frame, --no-frame framed gadget search (default on): on AArch64/RISC-V keep only gadgets that restore the return address from the stack; no effect on x86 + --reg-aliases allow sub-register aliases (al, ax, eax, ...) to substitute their full register when matching operations; they are then treated as the same register for chain assignment and side effects --allow-undeterministic-gadgets allow gadgets with conditional branches (e.g. jne) as intermediate instructions --allow-complex-memory-ops allow gadgets whose first instruction uses complex memory addressing (e.g. [r1*r2], [r1+r2*s+disp]) + --keep-contradictory keep 'contradictory' operation gadgets whose destination register is overwritten before the ret (e.g. `add rax, rbx ; mov rax, rcx ; ret`); by default these are filtered out of --op results --verbose show progress information (gadget counts, combinations) --binary [ ...] specify a list of binary path files to analyze @@ -79,9 +114,10 @@ options: --symbols annotate gadgets with the nearest symbol (when the binary is not stripped) --output {text,json,csv} output format (default: text) + --tuple print each gadget as the tuple (overrides --output text) --op search for operation - --dst specify a destination register for the operation - --src specify a source register for the operation + --operands [ ...] + operation operands, positionally (op1 op2 op3 ...); e.g. --op mov --operands rdi rax --ropchain plain text file with a ROP chain --exhaustive, --no-exhaustive exhaustive search for ROP chains @@ -93,7 +129,7 @@ options: ### Parallel scan -`--jobs N` distributes the gadget scan over `N` worker processes. Each executable section is split into chunks scanned independently, then the results are merged and deduplicated, so the output is identical to a serial run. The speedup is sublinear (the merge, deduplication and sort run in the parent, and there is per-process start-up cost), so it is worth it mainly for large binaries and/or a high `--depth`; on small inputs the process overhead dominates and `--jobs 1` (the default) is faster. +`--jobs N` distributes the gadget scan over `N` worker processes. Each executable section is split into chunks scanned independently, then the results are merged and deduplicated, so the output is identical to a serial run. The speedup is sublinear (the merge, deduplication and sort run in the parent, and there is per-process start-up cost), so it is worth it mainly for large binaries and/or a high `--depth`; on small inputs the process overhead dominates and `--jobs 1` (the default) is faster. Framed architectures (AArch64/RISC-V) scan serially regardless of `--jobs`. ### Gadget cache @@ -101,7 +137,7 @@ With `--cache`, the gadgets discovered for a binary are stored on disk and reuse ```Shell $ python rop3.py --binary libc.so.6 --cache # first run scans and caches -$ python rop3.py --binary libc.so.6 --cache --op mov --dst rdi --src rax # reuses the cache +$ python rop3.py --binary libc.so.6 --cache --op mov --operands rdi rax # reuses the cache ``` ### Interactive mode @@ -121,7 +157,7 @@ rop3> chain chain.txt rop3> quit ``` -Commands: `gadgets`/`search [substring]`, `count`, `op [dst] [src]`, `chain `, `help`, `quit`. +Commands: `gadgets`/`search [substring]`, `count`, `op [operands...]`, `chain `, `help`, `quit`. ### Use as a library @@ -134,14 +170,17 @@ r = Rop3("libc.so.6", base="0x7f0000000000", symbols=True) for gadget in r.gadgets(): print(gadget) -r.find_op("mov", dst="rdi", src="rax") # list of matching gadgets -r.ropchain("chain.txt") # iterator over ROP chains +r.find_op("mov", operands=["rdi", "rax"]) # list of matching gadgets +r.ropchain("chain.txt") # iterator over ROP chains + +for info in r.describe(): # per-binary summary (arch, bits, sections, ...) + print(info) ``` In the work that we presented in [15th IEEE Workshop on Offensive Technologies (WOOT21)](https://www.ieee-security.org/TC/SP2021/SPW2021/WOOT21/), we used rop3 to evaluate the executional power of Return Oriented Programming in a [subset of most common Windows DLLs](https://drive.google.com/file/d/1gOxUolzrw-xlaW6K-fhzZ7Z-sqxiaZeZ/view?usp=sharing>). Check the [paper](https://drive.google.com/file/d/1Pe7s7bLhJ_20MC-duQ7YiLP-Rx5VCjFK/view?usp=sharing) for further details. ```Shell -$ python rop3.py --binary ../tfg_inf/experiments/dlls/win10x86/SHELL32.dll --op mov --dst eax --src ecx +$ python rop3.py --binary ../tfg_inf/experiments/dlls/win10x86/SHELL32.dll --op mov --operands eax ecx [SHELL32.dll @ 0x698a474c]: mov eax, ecx ; ret (x97) [SHELL32.dll @ 0x698dc8c8]: mov eax, ecx ; pop ebx ; leave ; ret (x5) (modifies rbx, rbp) [SHELL32.dll @ 0x6991a2b1]: mov eax, ecx ; pop ebx ; ret (x4) (modifies rbx) diff --git a/requirements.txt b/requirements.txt index 19e4c7a..f7bb770 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -capstone >= 4.0.1 +capstone >= 5.0 pefile >= 2019.4.18 pyelftools >= 0.27 pyyaml >= 5.4 diff --git a/rop3/__init__.py b/rop3/__init__.py index b0c6404..a1d6961 100644 --- a/rop3/__init__.py +++ b/rop3/__init__.py @@ -23,7 +23,6 @@ import rop3.parser as parser import rop3.binary as binary import rop3.ropchain -import rop3.gadfinder as gadfinder from rop3.api import Rop3 @@ -40,26 +39,36 @@ def main(): rop = Rop3.from_args(args) try: + if args.verbose: + kinds = [k for k, on in (('ROP', args.rop), ('JOP', args.jop), + ('RETF', args.retf)) if on] + debug.info(f"search: {'+'.join(kinds) or 'none'}, " + f"depth {args.depth if args.depth is not None else 'auto'} bytes, " + f"{args.jobs} job(s)") + for info in rop.describe(): + for line in utils.binary_info_lines(info): + debug.info(line) + if args.interactive: from rop3.interactive import Rop3Shell Rop3Shell(rop).cmdloop() - elif args.ropchain: - result = rop.ropchain(args.ropchain) - utils.output_ropchains(result, args.output, exhaustive=args.exhaustive) - elif args.op: - result = rop.find_op(args.op, args.dst, args.src) - if result and isinstance(result[0], list): - ''' Composite operation: a list of chains ''' - if args.output == 'text': - for chain in result: - for gadget in chain: - utils.print_gadget(gadget) + else: + # --tuple renders gadgets as the tuple form; it overrides the + # textual --output (json/csv keep their structured formats). + out_fmt = 'tuple' if args.tuple else args.output + + if args.ropchain: + result = rop.ropchain(args.ropchain) + utils.output_ropchains(result, out_fmt, exhaustive=args.exhaustive) + elif args.op: + result = rop.find_op(args.op, operands=args.operands) + if result and isinstance(result[0], list): + ''' Composite operation: a list of chains ''' + utils.output_ropchains(result, out_fmt, exhaustive=True) else: - utils.output_ropchains(result, args.output, exhaustive=True) + utils.output_gadgets(result, out_fmt) else: - utils.output_gadgets(result, args.output) - else: - utils.output_gadgets(rop.gadgets(), args.output) + utils.output_gadgets(rop.gadgets(), out_fmt) except parser.ParserException as exc: debug.error(str(exc)) except rop3.ropchain.RopChainNotFound as exc: diff --git a/rop3/api.py b/rop3/api.py index 755a6d5..bef2772 100644 --- a/rop3/api.py +++ b/rop3/api.py @@ -18,6 +18,7 @@ import rop3.gadfinder as gadfinder from rop3.gadfinder import GadFinder from rop3.ropchain import RopChain +from rop3.binary import Binary class Rop3: @@ -28,16 +29,17 @@ class Rop3: from rop3 import Rop3 r = Rop3("libc.so.6", base="0x7f0000000000") r.gadgets() # list[Gadget] - r.find_op("mov", dst="rdi", src="rax") + r.find_op("mov", ["rdi", "rax"]) r.ropchain("chain.txt") The discovered gadgets are scanned once and cached on the instance, so repeated queries (and the interactive mode) do not re-scan the binary. ''' - def __init__(self, binaries, *, depth=gadfinder.DEPTH, rop=True, jop=False, + def __init__(self, binaries, *, depth=None, rop=True, jop=False, retf=False, all=False, allow_undeterministic=False, - allow_complex_mem=False, avoid_canary=True, base=None, + allow_complex_mem=False, avoid_canary=True, ret_imm=False, + reg_aliases=False, keep_contradictory=False, framed=True, base=None, badchars=None, badchar_bytes=None, arch=None, symbols=False, cache=False, cache_dir=None, jobs=1): self.binaries = [binaries] if isinstance(binaries, str) else list(binaries) @@ -62,6 +64,14 @@ def __init__(self, binaries, *, depth=gadfinder.DEPTH, rop=True, jop=False, flags |= gadfinder.ALLOW_COMPLEX_MEM if avoid_canary: flags |= gadfinder.AVOID_CANARY + if ret_imm: + flags |= gadfinder.ALLOW_RET_IMM + if reg_aliases: + flags |= gadfinder.ALLOW_REG_ALIASES + if keep_contradictory: + flags |= gadfinder.KEEP_CONTRADICTORY + if not framed: + flags |= gadfinder.UNFRAMED self._finder = GadFinder(depth, flags, cache=cache, cache_dir=cache_dir, jobs=jobs) @@ -97,9 +107,19 @@ def gadgets(self, refresh=False): symbols=self.symbols) return self._gadgets - def find_op(self, op, dst=None, src=None): - ''' Gadgets (or ROP chains, for composite ops) implementing `op`. ''' - return self._finder.find_op_from_gadgets(self.gadgets(), op, dst, src) + def describe(self): + ''' One metadata dict per input binary (format, architecture, pointer + width, instruction alignment, executable sections). Used for + verbose reporting; does not scan for gadgets. ''' + bases = self.base if isinstance(self.base, list) \ + else [self.base] * len(self.binaries) + return [Binary(fn, b, self.arch).describe() + for fn, b in zip(self.binaries, bases)] + + def find_op(self, op, operands=None): + ''' Gadgets (or ROP chains, for compound ops) implementing `op`. + `operands` are positional: op1, op2, op3, ... ''' + return self._finder.find_op_from_gadgets(self.gadgets(), op, operands) def ropchain(self, ropfile): ''' Iterator over ROP chains satisfying the operations in `ropfile`. ''' diff --git a/rop3/arch.py b/rop3/arch.py index 6b6e888..0a894b7 100644 --- a/rop3/arch.py +++ b/rop3/arch.py @@ -16,27 +16,203 @@ ''' from abc import ABC, abstractmethod -from typing import Optional, List, Any +from typing import List, Any + +import capstone + +from rop3.search import galileo_scan + +# Default search depth in bytes when the user does not pass --depth. Tuned for +# x86, whose 1-3 byte instructions pack several into a short gadget. Fixed-width +# ISAs (RISC-V, AArch64) need a larger window to fit even a two-instruction +# gadget and override `Architecture.default_depth`. +DEFAULT_DEPTH = 5 class Architecture(ABC): """Abstract base class for all architectures""" - + + # --- Byte-level gadget terminations (architecture specific) ------------- + @abstractmethod - def get_rop_terminations(self, include_extra: bool = False) -> List[str]: + def get_rop_terminations(self, **kwargs) -> List[dict]: pass @abstractmethod - def get_jop_terminations(self, include_extra: bool = False) -> List[str]: + def get_jop_terminations(self) -> List[dict]: pass + # --- Mnemonic classification (data supplied by each architecture) ------- + + @property @abstractmethod - def is_valid_rop_gadget(self, decodes: Any, include_extra: bool = False, allow_undeterministic: bool = False) -> bool: + def rop_termination_mnemonics(self) -> tuple[str, ...]: + ''' Mnemonics that legitimately terminate a ROP gadget (e.g. ret). ''' pass + @property @abstractmethod - def is_valid_jop_gadget(self, decodes: Any, include_extra: bool = False, allow_undeterministic: bool = False) -> bool: + def jop_termination_mnemonics(self) -> tuple[str, ...]: + ''' Mnemonics that legitimately terminate a JOP gadget (indirect + branch through a register). ''' pass + @property + @abstractmethod + def unconditional_branch_mnemonics(self) -> tuple[str, ...]: + ''' Unconditional control-flow transfers that make an intermediate + instruction split the gadget (jmp/call, j/jal, ...). ''' + pass + + @property + @abstractmethod + def conditional_branch_mnemonics(self) -> tuple[str, ...]: + ''' Conditional branches; only rejected when undeterministic gadgets + are disallowed. ''' + pass + + @property + def mnemonic_prefixes(self) -> tuple[str, ...]: + ''' Instruction-level prefixes that decorate a mnemonic but do not + change its class (e.g. x86 `bnd`, `notrack`). Default: none. ''' + return () + + def base_mnemonic(self, mnemonic: str) -> str: + ''' The mnemonic with any architecture prefixes stripped. ''' + for part in mnemonic.split(): + if part not in self.mnemonic_prefixes: + return part + return mnemonic + + def _has_ret_imm(self, decodes: Any, terminations: tuple[str, ...]) -> bool: + ''' Whether any instruction is a return-with-immediate (which returns + at that point, shortening the gadget). Architectures without such a + form (RISC-V) inherit False. ''' + return False + + def _terminates_rop(self, insn: Any, terminations: tuple[str, ...]) -> bool: + ''' Whether the final instruction returns control the way a ROP + gadget's tail does. Default: an exact terminator-mnemonic match. + Architectures whose return shares a mnemonic with other branches + (RISC-V `c.jr ra`) override this to inspect the operand. ''' + return insn.mnemonic in terminations + + def is_valid_jop_last(self, insn: Any) -> bool: + ''' Whether the final instruction is a usable indirect branch target + (i.e. through a register/memory operand, not an immediate). ''' + return True + + def _rop_terminations(self, **kwargs) -> tuple[str, ...]: + return self.rop_termination_mnemonics + + # --- Shared gadget-validity algorithm (template methods) ---------------- + + def is_valid_rop_gadget(self, decodes: Any, + allow_undeterministic: bool = False, + allow_ret_imm: bool = False, **kwargs) -> bool: + if not decodes: + return False + + terminations = self._rop_terminations(**kwargs) + + if not self._terminates_rop(decodes[-1], terminations): + return False + + # A return-with-immediate returns at that point, so a gadget containing + # one anywhere behaves as a ret-imm gadget; exclude it unless allowed. + if not allow_ret_imm and self._has_ret_imm(decodes, terminations): + return False + + intermediates = decodes[1:-1] + + # Intermediate termination (there is already a shorter version). + if any(self.base_mnemonic(ins.mnemonic) in terminations for ins in intermediates): + return False + # Multibranch unconditional (jmp/call, j/jal). + if any(self.base_mnemonic(ins.mnemonic) in self.unconditional_branch_mnemonics + for ins in intermediates): + return False + # Multibranch conditional (je/jne, beq/bne). + if not allow_undeterministic and any( + ins.mnemonic in self.conditional_branch_mnemonics for ins in intermediates): + return False + return True + + def is_valid_jop_gadget(self, decodes: Any, + allow_undeterministic: bool = False) -> bool: + if not decodes: + return False + + terminations = self.jop_termination_mnemonics + last = decodes[-1] + + if self.base_mnemonic(last.mnemonic) not in terminations: + return False + + if not self.is_valid_jop_last(last): + return False + + intermediates = decodes[1:-1] + + # Multibranch unconditional (jmp/call, j/jal). + if any(self.base_mnemonic(ins.mnemonic) in self.unconditional_branch_mnemonics + for ins in intermediates): + return False + # Multibranch conditional (je/jne, beq/bne). + if not allow_undeterministic and any( + ins.mnemonic in self.conditional_branch_mnemonics for ins in intermediates): + return False + return True + + # --- Capstone / ABI descriptors ----------------------------------------- + + @property + def name(self) -> str: + ''' Human-readable architecture name, used for verbose reporting. ''' + return type(self).__name__ + + @property + def scan_name(self) -> str: + ''' Short descriptive label for the gadget-search strategy this + architecture uses -- for verbose reporting only, never dispatch. ''' + return 'galileo' + + @property + def parallelizable(self) -> bool: + ''' Whether this architecture's scan can be split into byte-offset + chunks and run across worker processes (see + GadFinder._scan_parallel). Only the Galileo backward walk supports + it; the linear-sweep strategies run single-threaded. ''' + return True + + @property + def default_depth(self) -> int: + ''' Search depth in bytes used when the user does not pass --depth. + Fixed-width ISAs override this: on RISC-V a framed ROP gadget needs + at least `ld ra, off(sp) ; ret` (8 bytes), so the x86 default of + DEFAULT_DEPTH would find nothing. ''' + return DEFAULT_DEPTH + + def scan(self, opcodes, base_vaddr, depth, disasm, is_valid_gadget, + terminations=None, accept_candidate=None, accept_match=None, + framed=True): + ''' + Yield this architecture's gadgets within one executable section as + ``(vaddr, raw, decodes)`` tuples. Each architecture wires the search + strategy (see rop3.search) that fits its ISA; the finder calls this + uniformly and never branches on the architecture. + + The default is the Galileo backward walk -- required on variable-length + (x86) ISAs, where gadgets hide inside longer instructions -- driven by + the byte-pattern `terminations` the finder supplies. `accept_match` + partitions terminations across parallel chunks (see _scan_parallel) and + is ignored by strategies that do not chunk. `framed` is honored only by + architectures with a framed scan (AArch64, RISC-V); Galileo ignores it. + ''' + yield from galileo_scan( + opcodes, base_vaddr, terminations, depth, self.alignment, disasm, + is_valid_gadget, accept_match=accept_match, + accept_candidate=accept_candidate) + @property @abstractmethod def arch(self) -> int: @@ -47,6 +223,22 @@ def arch(self) -> int: def mode(self) -> int: pass + @property + @abstractmethod + def address_size(self) -> int: + ''' Pointer width in bytes (4 for 32-bit, 8 for 64-bit). Drives + address packing/formatting independently of the capstone mode + constant (which is not a clean 32/64 flag on every architecture). ''' + pass + + @property + def alignment(self) -> int: + ''' Minimum instruction alignment in bytes. Gadgets may only start (and + terminate) at addresses that are a multiple of this value. x86 is + byte-aligned (1); RISC-V is 4-byte aligned, or 2-byte when the + compressed (C) extension is present. ''' + return 1 + @property @abstractmethod def op_reg(self) -> int: @@ -72,6 +264,15 @@ def sp(self) -> str: def bp(self) -> str: pass + @property + def flags(self) -> str | None: + """ + Name of the architecture's condition/flags register, spelled as capstone + reports it (e.g. x86-64 'rflags', x86-32 'eflags', AArch64 'nzcv'), or + None when the architecture has no flags register (RISC-V). + """ + return None + def normalize_reg(self, name: str | int) -> str: """ Standard instance method. Base implementation just returns the name, @@ -94,9 +295,66 @@ def first_insn_has_complex_mem(self, decodes) -> bool: """ return False + def is_frame_prefix(self, insn) -> bool: + """ + Whether `insn` may appear in a gadget's frame prologue -- the leading + run of instructions that an operation is allowed to follow. When + matching an operation, this prologue is skipped, so the operation's + first instruction must be the first instruction after it (position 0 + when the prologue is empty). Default: no prologue (the operation must be + the gadget's first instruction). RISC-V overrides this to allow the ra + restore that frames a real ROP gadget. + """ + return False + + def is_return(self, insn) -> bool: + """ + Whether `insn` returns control the way a ROP gadget's tail does. The + framed scan uses this to require a preceding frame load. Default: no + architecture-specific return recognition (only framed-scan archs need it). + """ + return False + + def is_frame_load(self, insn) -> bool: + """ + Whether `insn` establishes the gadget's return frame by restoring the + return target from the stack (e.g. RISC-V `ld ra, off(sp)`). The framed + scan emits a return gadget only once its run covers one. Default: none. + """ + return False + + def written_registers(self, insn) -> set: + """ + Capstone register ids written by `insn`, explicit and implicit. The + default relies on capstone's ``regs_access()`` helper (implemented for + x86); architectures for which capstone does not provide it override + this. Used to compute a gadget's clobbered ("side effect") registers. + """ + try: + _, writes = insn.regs_access() + except capstone.CsError: + # regs_access() is unimplemented for this architecture; fall back + # to the (implicit-only) detail array. + writes = () + return set(insn.regs_write) | set(writes) + + def read_registers(self, insn) -> set: + """ + Capstone register ids read by `insn`, explicit and implicit. Mirror of + written_registers: prefers regs_access() and falls back to the + (implicit-only) detail array where capstone does not implement it. Used + for the read set of a gadget's tuple representation. + """ + try: + reads, _ = insn.regs_access() + except capstone.CsError: + reads = () + return set(insn.regs_read) | set(reads) + class ArchitectureSingleton: def __init__(self): self._arch = None + self.allow_reg_aliases = False def initialize(self, arch: Architecture): if self._arch is not None: @@ -106,6 +364,7 @@ def initialize(self, arch: Architecture): def reset(self) -> None: ''' Clear the current architecture (mainly for tests / library use) ''' self._arch = None + self.allow_reg_aliases = False def is_initialized(self) -> bool: return self._arch is not None diff --git a/rop3/archs/aarch64_arch.py b/rop3/archs/aarch64_arch.py new file mode 100644 index 0000000..0673c3e --- /dev/null +++ b/rop3/archs/aarch64_arch.py @@ -0,0 +1,187 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import capstone.arm64_const as arm64_const +from rop3.arch import Architecture +from rop3.search import aligned_scan, framed_aligned_scan + +# ABI register names capstone prints for the 64-bit integer file. xzr (the +# hardwired zero register) is not a usable destination and is excluded. +REGS: frozenset[str] = frozenset( + {f'x{i}' for i in range(31)} | {'sp', 'lr', 'fp'} +) + +# Direct (b/bl) and indirect (br/blr) branches. All split a gadget. +UNCONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( + 'b', 'bl', 'br', 'blr', 'ret' +) + +# Indirect branches usable as JOP terminations (a `ret` is handled as ROP). +JOP_TERMINATION_MNEMONICS: tuple[str, ...] = ( + 'br', 'blr', +) + +CONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( + 'b.eq', 'b.ne', 'b.cs', 'b.hs', 'b.cc', 'b.lo', 'b.mi', 'b.pl', + 'b.vs', 'b.vc', 'b.hi', 'b.ls', 'b.ge', 'b.lt', 'b.gt', 'b.le', + 'b.al', 'b.nv', + 'cbz', 'cbnz', 'tbz', 'tbnz', +) + +# First byte of a `RET/BR/BLR Rn`: the register number's low 3 bits sit in bits +# 7:5, so byte 0 is (Rn & 7) << 5. +_RN_LOW = b'[\x00\x20\x40\x60\x80\xa0\xc0\xe0]' + + +class AArch64_Architecture(Architecture): + ''' + AArch64 (ARM64). A fixed-width (every instruction is 4 bytes), naturally + 4-byte-aligned ISA, so gadgets can only begin on instruction boundaries and + the aligned linear-sweep search both suffices and is faster than Galileo + (see `scan`). + ''' + + @property + def scan_name(self) -> str: + return 'aligned' + + @property + def parallelizable(self) -> bool: + # The linear sweep is single-pass and not chunkable by byte offset. + return False + + @property + def default_depth(self) -> int: + # Fixed 4-byte instructions: allow ~5 of them so multi-instruction + # gadgets are found, not just a lone `ret`. + return 20 + + def scan(self, opcodes, base_vaddr, depth, disasm, is_valid_gadget, + terminations=None, accept_candidate=None, accept_match=None, + framed=True): + # Fixed-width, naturally aligned ISA: the aligned linear sweep finds the + # same gadgets as Galileo, faster, with no unintended gadgets. When + # framed, keep only gadgets that restore the return address (lr/x30) + # from the stack before returning. Byte `terminations`/`accept_match` + # (Galileo-only) are unused here. + if framed: + yield from framed_aligned_scan( + opcodes, base_vaddr, depth, self.alignment, disasm, + is_valid_gadget, self.is_frame_load, self.is_return, + accept_candidate=accept_candidate) + else: + yield from aligned_scan( + opcodes, base_vaddr, depth, self.alignment, disasm, + is_valid_gadget, accept_candidate=accept_candidate) + + @property + def name(self) -> str: + return 'AArch64 (ARM64)' + + # --- Byte-level gadget terminations ------------------------------------- + # Only consulted if this architecture is ever routed through Galileo; the + # legal scan finds terminations by disassembly. Provided for completeness. + + def get_rop_terminations(self, **kwargs): + # RET Rn = 0xD65F0000 | (Rn << 5); defaults to x30 (0xD65F03C0). + return [{'bytes': _RN_LOW + b'[\x00-\x03]\x5f\xd6', 'size': 4}] + + def get_jop_terminations(self): + # BR Rn = 0xD61F0000 | (Rn << 5); BLR Rn = 0xD63F0000 | (Rn << 5). + return [{'bytes': _RN_LOW + b'[\x00-\x03][\x1f\x3f]\xd6', 'size': 4}] + + # --- Mnemonic classification -------------------------------------------- + + @property + def rop_termination_mnemonics(self) -> tuple[str, ...]: + return ('ret',) + + @property + def jop_termination_mnemonics(self) -> tuple[str, ...]: + return JOP_TERMINATION_MNEMONICS + + @property + def unconditional_branch_mnemonics(self) -> tuple[str, ...]: + return UNCONDITIONAL_BRANCH_MNEMONICS + + @property + def conditional_branch_mnemonics(self) -> tuple[str, ...]: + return CONDITIONAL_BRANCH_MNEMONICS + + # --- Capstone / ABI descriptors ----------------------------------------- + + @property + def arch(self): + return capstone.CS_ARCH_ARM64 + + @property + def mode(self): + return capstone.CS_MODE_ARM + + @property + def address_size(self) -> int: + return 8 + + @property + def alignment(self) -> int: + return 4 + + @property + def op_reg(self): + return arm64_const.ARM64_OP_REG + + @property + def op_mem(self): + return arm64_const.ARM64_OP_MEM + + @property + def op_imm(self): + return arm64_const.ARM64_OP_IMM + + @property + def sp(self) -> str: + return 'sp' + + @property + def bp(self) -> str: + return 'x29' + + @property + def flags(self) -> str: + return 'nzcv' + + def is_valid_abstract_reg(self, name: str | int) -> bool: + return str(name) in REGS + + def is_return(self, insn) -> bool: + ''' A `ret` (branches to lr/x30). Drives the framed scan's requirement + that a ROP gadget restore lr from the stack. ''' + return self.base_mnemonic(insn.mnemonic) == 'ret' + + def is_frame_load(self, insn) -> bool: + ''' Whether `insn` restores the return address (lr/x30) from the stack, + e.g. `ldr x30, [sp, #off]` or `ldp x29, x30, [sp], #off`. Capstone + exposes lr as a register operand and sp as the memory base. ''' + if self.base_mnemonic(insn.mnemonic) not in ('ldr', 'ldp'): + return False + ops = insn.operands + loads_lr = any(op.type == self.op_reg and insn.reg_name(op.reg) in ('x30', 'lr') + for op in ops) + from_stack = any(op.type == self.op_mem and insn.reg_name(op.mem.base) in ('sp', 'wsp') + for op in ops) + return loads_lr and from_stack diff --git a/rop3/archs/riscv_arch.py b/rop3/archs/riscv_arch.py new file mode 100644 index 0000000..d0ac18c --- /dev/null +++ b/rop3/archs/riscv_arch.py @@ -0,0 +1,262 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import capstone.riscv_const as riscv_const +from rop3.arch import Architecture +from rop3.search import aligned_scan, framed_aligned_scan + +# ABI register names capstone prints for the integer file (x0 is the hardwired +# zero register and is not a usable destination, so it is excluded). +REGS: frozenset[str] = frozenset({ + 'ra', 'sp', 'gp', 'tp', + 't0', 't1', 't2', 't3', 't4', 't5', 't6', + 's0', 'fp', 's1', 's2', 's3', 's4', 's5', 's6', + 's7', 's8', 's9', 's10', 's11', + 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', +}) + +# Unconditional transfers. `ret`/`jr`/`jalr` are indirect (register targets); +# `j`/`jal` are direct. Compressed variants share the same printed mnemonics. +UNCONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( + 'j', 'jal', 'jalr', 'jr', + 'c.j', 'c.jal', 'c.jalr', 'c.jr', +) + +# Indirect branches usable as JOP terminations (a `ret` is handled as ROP). +JOP_TERMINATION_MNEMONICS: tuple[str, ...] = ( + 'jr', 'jalr', 'c.jr', 'c.jalr', +) + +CONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( + 'beq', 'bne', 'blt', 'bge', 'bltu', 'bgeu', + 'beqz', 'bnez', 'blez', 'bgez', 'bltz', 'bgtz', + 'c.beqz', 'c.bnez', +) + +# Store instructions: their first operand is the source register (rs2), not a +# destination -- they write memory, not a register. +STORE_MNEMONICS: tuple[str, ...] = ( + 'sb', 'sh', 'sw', 'sd', + 'fsb', 'fsh', 'fsw', 'fsd', 'fsq', + 'c.sw', 'c.sd', 'c.swsp', 'c.sdsp', + 'c.fsw', 'c.fsd', 'c.fswsp', 'c.fsdsp', +) + +# Instructions whose first register operand is read, not written -- so they +# have no destination register in operand 0. Everything else that has a leading +# register operand writes it (rd is always the first operand on RISC-V). +NON_WRITING_MNEMONICS: frozenset[str] = frozenset( + STORE_MNEMONICS + CONDITIONAL_BRANCH_MNEMONICS + + ('jr', 'c.jr', 'ret', 'c.jalr') +) + +# Integer loads. A RISC-V ROP gadget must reload ra (x1) from the stack before +# `ret`; the canonical restore is `ld ra, off(sp)` (or compressed `c.ldsp`). +LOAD_MNEMONICS: frozenset[str] = frozenset({ + 'ld', 'lw', 'lwu', 'lh', 'lhu', 'lb', 'lbu', + 'c.ldsp', 'c.lwsp', 'c.ld', 'c.lw', +}) + + +class RISCV_Architecture(Architecture): + ''' + RISC-V (RV64I) architecture. `compressed` reflects the presence of the C + (compressed) extension in the binary (ELF `e_flags & EF_RISCV_RVC`): it + both enables capstone's 16-bit decoding and relaxes the instruction + alignment from 4 to 2 bytes. + ''' + + def __init__(self, compressed: bool = False): + self._compressed = bool(compressed) + + @property + def scan_name(self) -> str: + return 'framed aligned' + + @property + def parallelizable(self) -> bool: + # The linear sweep is single-pass and not chunkable by byte offset. + return False + + @property + def default_depth(self) -> int: + # A framed ROP gadget needs at least `ld ra, off(sp) ; ret` (8 bytes); + # real epilogues run longer. Give ~6 base instructions of room (more + # when compressed) so the default finds real gadgets. + return 24 + + def scan(self, opcodes, base_vaddr, depth, disasm, is_valid_gadget, + terminations=None, accept_candidate=None, accept_match=None, + framed=True): + # `ret` jumps through ra, so a useful ROP gadget must first restore ra + # from the stack: the aligned sweep gated on that frame load (the + # default). `--no-frame` drops the requirement (plain aligned sweep). + # Byte `terminations`/`accept_match` (Galileo-only) are unused here. + if framed: + yield from framed_aligned_scan( + opcodes, base_vaddr, depth, self.alignment, disasm, + is_valid_gadget, self.is_frame_load, self.is_return, + accept_candidate=accept_candidate) + else: + yield from aligned_scan( + opcodes, base_vaddr, depth, self.alignment, disasm, + is_valid_gadget, accept_candidate=accept_candidate) + + @property + def compressed(self) -> bool: + ''' Whether the binary advertises the C (compressed) extension. ''' + return self._compressed + + @property + def name(self) -> str: + return 'RISC-V RV64' + (' (compressed)' if self._compressed else '') + + # --- Byte-level gadget terminations ------------------------------------- + + def get_rop_terminations(self, **kwargs): + # `ret` is the canonical return, encoded as `jalr x0, 0(ra)` (0x00008067) + # or, with the C extension, `c.jr ra` (0x8082). + ret = [{'bytes': b'\x67\x80\x00\x00', 'size': 4}] # jalr x0, 0(ra) + if self._compressed: + ret.append({'bytes': b'\x82\x80', 'size': 2}) # c.jr ra + return ret + + def get_jop_terminations(self): + # `jalr rd, imm(rs1)` has opcode 0b1100111 (0x67) in the low 7 bits, so + # its first byte is 0x67 or 0xe7 (rd bit 0 sits at bit 7). The candidate + # is re-validated after disassembly, so a broad match is safe. + ret = [{'bytes': b'[\x67\xe7][\x00-\xff]{3}', 'size': 4}] + if self._compressed: + # c.jr/c.jalr rs1: bits[15:13]=100, bits[6:2]=0, bits[1:0]=10. + ret.append({'bytes': b'[\x02\x82][\x80-\x9f]', 'size': 2}) + return ret + + # --- Mnemonic classification -------------------------------------------- + + @property + def rop_termination_mnemonics(self) -> tuple[str, ...]: + return ('ret',) + + def _terminates_rop(self, insn, terminations) -> bool: + # The canonical return is `ret` (jalr x0, 0(ra)). Capstone renders the + # compressed form `c.jr ra` under its own mnemonic, so recognize it by + # its target register; other `c.jr ` are plain indirect jumps. + if insn.mnemonic == 'ret': + return True + return insn.mnemonic == 'c.jr' and insn.op_str.strip() == 'ra' + + @property + def jop_termination_mnemonics(self) -> tuple[str, ...]: + return JOP_TERMINATION_MNEMONICS + + @property + def unconditional_branch_mnemonics(self) -> tuple[str, ...]: + return UNCONDITIONAL_BRANCH_MNEMONICS + + @property + def conditional_branch_mnemonics(self) -> tuple[str, ...]: + return CONDITIONAL_BRANCH_MNEMONICS + + # --- Capstone / ABI descriptors ----------------------------------------- + + @property + def arch(self): + return capstone.CS_ARCH_RISCV + + @property + def mode(self): + mode = capstone.CS_MODE_RISCV64 + if self._compressed: + mode |= capstone.CS_MODE_RISCVC + return mode + + @property + def address_size(self) -> int: + return 8 + + @property + def alignment(self) -> int: + # Base ISA instructions are 4-byte aligned; the C extension allows + # 2-byte alignment. + return 2 if self._compressed else 4 + + @property + def op_reg(self): + return riscv_const.RISCV_OP_REG + + @property + def op_mem(self): + return riscv_const.RISCV_OP_MEM + + @property + def op_imm(self): + return riscv_const.RISCV_OP_IMM + + @property + def sp(self) -> str: + return 'sp' + + @property + def bp(self) -> str: + # RISC-V has no dedicated frame pointer; s0 (x8) is used by convention. + return 's0' + + def is_valid_abstract_reg(self, name: str | int) -> bool: + return str(name) in REGS + + def is_return(self, insn) -> bool: + ''' Whether `insn` is a return (`ret` / `c.jr ra`). Drives the framed + scan's requirement that ROP gadgets restore ra from the stack. ''' + return self._terminates_rop(insn, self.rop_termination_mnemonics) + + def is_frame_load(self, insn) -> bool: + ''' The framed-scan frame load on RISC-V is the ra restore. ''' + return self.is_ra_load(insn) + + def is_frame_prefix(self, insn) -> bool: + ''' The ra restore frames a RISC-V ROP gadget, so an operation may + follow it (e.g. `ld ra, off(sp) ; add a0, a1, a2 ; ret`). ''' + return self.is_ra_load(insn) + + def is_ra_load(self, insn) -> bool: + ''' Whether `insn` loads ra (x1) from the stack, e.g. `ld ra, off(sp)` + or `c.ldsp ra, off`. Capstone renders the base sp either as a memory + operand's base (`ld`) or as a bare register operand (`c.ldsp`). ''' + if self.base_mnemonic(insn.mnemonic) not in LOAD_MNEMONICS: + return False + ops = insn.operands + if not ops or ops[0].type != self.op_reg or insn.reg_name(ops[0].reg) != 'ra': + return False + for op in ops[1:]: + if op.type == self.op_mem and insn.reg_name(op.mem.base) == 'sp': + return True + if op.type == self.op_reg and insn.reg_name(op.reg) == 'sp': + return True + return False + + def written_registers(self, insn) -> set: + # capstone implements neither regs_access() nor per-operand access + # flags for RISC-V, so derive the destination from the encoding: rd is + # always the first operand and is written by every instruction except + # stores, branches and register-reading jumps. + if self.base_mnemonic(insn.mnemonic) in NON_WRITING_MNEMONICS: + return set() + ops = insn.operands + if ops and ops[0].type == self.op_reg: + return {ops[0].reg} + return set() diff --git a/rop3/archs/x86_arch.py b/rop3/archs/x86_arch.py index 069aaa8..8ff683b 100644 --- a/rop3/archs/x86_arch.py +++ b/rop3/archs/x86_arch.py @@ -59,7 +59,7 @@ ) UNCONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( - 'jmp', 'call', + 'jmp', 'call', 'ret', 'retf' ) CONDITIONAL_BRANCH_MNEMONICS: tuple[str, ...] = ( @@ -72,30 +72,61 @@ 'loop', 'loope', 'loopne', ) -def _base_mnemonic(mnemonic: str) -> str: - parts = mnemonic.split() - for part in parts: - if part not in MNEMONIC_PREFIXES: - return part - return mnemonic +class X86_Architecture(Architecture): + # --- Mnemonic classification consumed by the shared validity algorithm --- + @property + def rop_termination_mnemonics(self) -> tuple[str, ...]: + return ('ret',) -class X86_Architecture(Architecture): - def get_rop_terminations(self, include_extra: bool = False): - ret = [] - ret.extend([ - {'bytes': b'\xc3', 'size': 1}, # ret - {'bytes': b'\xc2[\x00-\xff]{2}', 'size': 3}, # ret - ]) - if include_extra: - ret.extend([ - {'bytes': b'\xcb', 'size': 1}, # retf - {'bytes': b'\xca[\x00-\xff]{2}', 'size': 3} # retf - ]) + @property + def jop_termination_mnemonics(self) -> tuple[str, ...]: + return ('jmp', 'call') + + @property + def unconditional_branch_mnemonics(self) -> tuple[str, ...]: + return UNCONDITIONAL_BRANCH_MNEMONICS + + @property + def conditional_branch_mnemonics(self) -> tuple[str, ...]: + return CONDITIONAL_BRANCH_MNEMONICS + + @property + def mnemonic_prefixes(self) -> tuple[str, ...]: + return MNEMONIC_PREFIXES + + def _has_ret_imm(self, decodes, terminations: tuple[str, ...]) -> bool: + # A `ret ` / `retf ` carries an immediate operand and returns + # at that point, so a gadget containing one anywhere behaves as a + # ret-imm gadget. + return any(self.base_mnemonic(ins.mnemonic) in terminations and ins.operands + for ins in decodes) + + def is_valid_jop_last(self, insn) -> bool: + # The \xff byte pattern can appear inside an imm operand of another + # instruction (e.g. e9 .. ff e0 ..). After disassembly, an immediate + # target is not a usable indirect branch. + return bool(insn.operands) and insn.operands[0].type != x86_const.X86_OP_IMM + + def _rop_terminations(self, include_retf: bool = False, **kwargs) -> tuple[str, ...]: + # retf is a valid ROP terminator only when far-return gadgets are asked + # for; x86 is the only architecture with this form. + if include_retf: + return self.rop_termination_mnemonics + ('retf',) + return self.rop_termination_mnemonics + + def get_rop_terminations(self, include_retf: bool = False, include_ret_imm: bool = False, **kwargs): + ret = [{'bytes': b'\xc3', 'size': 1}] # ret + if include_ret_imm: + ret.append({'bytes': b'\xc2[\x00-\xff]{2}', 'size': 3}) # ret + if include_retf: + ret.append({'bytes': b'\xcb', 'size': 1}) # retf + if include_ret_imm: + ret.append({'bytes': b'\xca[\x00-\xff]{2}', 'size': 3}) # retf return ret - def get_jop_terminations(self, include_extra: bool = False): + def get_jop_terminations(self): return [ {'bytes': b'\xff[\x20\x21\x22\x23\x26\x27]{1}', 'size': 2}, # jmp [reg] {'bytes': b'\xff[\xe0\xe1\xe2\xe3\xe4\xe6\xe7]{1}', 'size': 2}, # jmp [reg] @@ -103,6 +134,10 @@ def get_jop_terminations(self, include_extra: bool = False): {'bytes': b'\xff[\xd0\xd1\xd2\xd3\xd4\xd6\xd7]{1}', 'size': 2} # call [reg] ] + @property + def name(self) -> str: + return 'x86' + @property def arch(self): return capstone.CS_ARCH_X86 @@ -111,53 +146,9 @@ def arch(self): def mode(self): return capstone.CS_MODE_32 - def is_valid_rop_gadget(self, decodes, include_extra: bool = False, allow_undeterministic: bool = False): - if include_extra: - terminations = ('ret', 'retf') - else: - terminations = ('ret',) - - if decodes[-1].mnemonic not in terminations: - return False - - # Intermediate operation checks - intermediates = decodes[1:-1] - - # Intermediate ret (there is already a shorter version of the gadget) - if [ins for ins in intermediates if _base_mnemonic(ins.mnemonic) in terminations]: - return False - - # Multibranch unconditional (jmp, call) - if [ins for ins in intermediates if _base_mnemonic(ins.mnemonic) in UNCONDITIONAL_BRANCH_MNEMONICS]: - return False - # Multibranch conditional (je, jne) - if not allow_undeterministic and [ins for ins in intermediates if ins.mnemonic in CONDITIONAL_BRANCH_MNEMONICS]: - return False - return True - - def is_valid_jop_gadget(self, decodes, include_extra: bool = False, allow_undeterministic: bool = False): - terminations = ('jmp', 'call') - last = decodes[-1] - - if _base_mnemonic(last.mnemonic) not in terminations: - return False - - # The \xff byte pattern can appear inside an imm operand of another - # instruction (e.g. e9 .. ff e0 ..). After disassembly, filter those out. - if not last.operands or last.operands[0].type == x86_const.X86_OP_IMM: - return False - - # Intermediate operation checks - intermediates = decodes[1:-1] - - # Multibranch unconditional (jmp, call) - if [ins for ins in intermediates if _base_mnemonic(ins.mnemonic) in UNCONDITIONAL_BRANCH_MNEMONICS]: - return False - # Multibranch conditional (je, jne) - if not allow_undeterministic and [ins for ins in intermediates if ins.mnemonic in CONDITIONAL_BRANCH_MNEMONICS]: - return False - - return True + @property + def address_size(self) -> int: + return 4 @property def op_reg(self): @@ -179,6 +170,10 @@ def sp(self) -> str: def bp(self) -> str: return 'ebp' + @property + def flags(self) -> str: + return 'eflags' + def first_insn_has_complex_mem(self, decodes) -> bool: first = decodes[0] for op in first.operands: @@ -208,10 +203,18 @@ def is_valid_abstract_reg(self, name: str | int) -> bool: return False class X64_Architecture(X86_Architecture): + @property + def name(self) -> str: + return 'x86-64' + @property def mode(self): return capstone.CS_MODE_64 + @property + def address_size(self) -> int: + return 8 + @property def _canonical_width(self) -> int: return 8 @@ -224,6 +227,10 @@ def sp(self) -> str: def bp(self) -> str: return 'rbp' + @property + def flags(self) -> str: + return 'rflags' + def is_valid_abstract_reg(self, name: str | int) -> bool: """ Only accept 8 byte registers diff --git a/rop3/args.py b/rop3/args.py index db34486..04bad1e 100644 --- a/rop3/args.py +++ b/rop3/args.py @@ -29,13 +29,17 @@ def __init__(self): description = 'This tool allows you to search for gadgets, operations, and ROP chains using a backtracking algorithm in a tree-like structure' self.argparser = argparse.ArgumentParser(description=description) self.argparser.add_argument('-v', '--version', action='store_true', help=f'display {utils.TOOL_NAME}\'s version and exit') - self.argparser.add_argument('--depth', type=int, metavar='', default=gadfinder.DEPTH, help=f'depth for search engine (default to {gadfinder.DEPTH} bytes)') + self.argparser.add_argument('--depth', type=int, metavar='', default=None, help='maximum gadget length in bytes (default: architecture-specific)') self.argparser.add_argument('--all', default=False, action='store_true', help='show the same gadget in different addresses') self.argparser.add_argument('--rop', action=argparse.BooleanOptionalAction, help="search for ROP gadgets", default=True) self.argparser.add_argument('--retf', action=argparse.BooleanOptionalAction, help="search for RETF gadgets", default=False) + self.argparser.add_argument('--ret-imm', action=argparse.BooleanOptionalAction, default=False, help='include gadgets ending in a `ret ` / `retf ` (disabled by default)') self.argparser.add_argument('--jop', action=argparse.BooleanOptionalAction, help="search for JOP gadgets", default=False) + self.argparser.add_argument('--frame', action=argparse.BooleanOptionalAction, default=True, help='framed gadget search (default on): on AArch64/RISC-V keep only gadgets that restore the return address from the stack; no effect on x86') + self.argparser.add_argument('--reg-aliases', action='store_true', default=False, help='allow sub-register aliases (al, ax, eax, ...) to substitute their full register when matching operations; they are then treated as the same register for chain assignment and side effects') self.argparser.add_argument('--allow-undeterministic-gadgets', action='store_true', default=False, help='allow gadgets with conditional branches (e.g. jne) as intermediate instructions') self.argparser.add_argument('--allow-complex-memory-ops', action='store_true', default=False, help='allow gadgets whose first instruction uses complex memory addressing (e.g. [r1*r2], [r1+r2*s+disp])') + self.argparser.add_argument('--keep-contradictory', action='store_true', default=False, help="keep 'contradictory' operation gadgets whose destination register is overwritten before the ret (e.g. `add rax, rbx ; mov rax, rcx ; ret`); by default these are filtered out of --op results") self.argparser.add_argument('--verbose', action='store_true', default=False, help='show progress information (gadget counts, combinations)') self.argparser.add_argument('--binary', type=str, metavar='', nargs='+', help='specify a list of binary path files to analyze') self.argparser.add_argument('--badchar', type=str, metavar='', nargs='+', help='specify a list of chars to avoid in gadget address') @@ -45,9 +49,13 @@ def __init__(self): self.argparser.add_argument('--arch', type=str, metavar='', default=None, help='select the architecture slice of a fat Mach-O binary (e.g. x86_64, i386)') self.argparser.add_argument('--symbols', action='store_true', default=False, help='annotate gadgets with the nearest symbol (when the binary is not stripped)') self.argparser.add_argument('--output', choices=['text', 'json', 'csv'], default='text', help='output format (default: text)') + self.argparser.add_argument('--tuple', action='store_true', default=False, help='print each gadget as the tuple (overrides --output text)') self.argparser.add_argument('--op', type=str, metavar='', help='search for operation') - self.argparser.add_argument('--dst', type=str, metavar='', help='specify a destination register for the operation') - self.argparser.add_argument('--src', type=str, metavar='', help='specify a source register for the operation') + self.argparser.add_argument('--operands', type=str, metavar='', nargs='+', help='operation operands, positionally (op1 op2 op3 ...); e.g. --op mov --operands rdi rax') + # LEGACY + self.argparser.add_argument('--dst', type=str, metavar='', default=None, help='[legacy] destination operand; maps to op1 on its own, or op1 when --src is also given. Prefer --operands') + # LEGACY + self.argparser.add_argument('--src', type=str, metavar='', default=None, help='[legacy] source operand; maps to op1 on its own, or op2 when --dst is also given. Prefer --operands') self.argparser.add_argument('--ropchain', type=str, metavar='', help='plain text file with a ROP chain') self.argparser.add_argument('--exhaustive', action=argparse.BooleanOptionalAction, help="exhaustive search for ROP chains", default=False) self.argparser.add_argument('--interactive', action='store_true', default=False, help='scan the binary once and drop into an interactive prompt') @@ -61,10 +69,34 @@ def parse_args(self, arguments): self._check_args(args) args = self._convert_flags(args) + args = self._convert_operands(args) args = self._convert_base(args) return args + def _convert_operands(self, args): + ''' + LEGACY: --dst/--src predate the positional --operands. A lone --dst or + --src maps to op1; giving both maps --dst to op1 and --src to op2. Kept + for backward compatibility only -- prefer --operands. + ''' + dst = getattr(args, 'dst', None) + src = getattr(args, 'src', None) + if dst is None and src is None: + return args + + debug.warning('--dst/--src are legacy; use --operands (positional: op1 op2 ...) instead') + + if args.operands: + debug.error('--dst/--src cannot be combined with --operands') + + if dst is not None and src is not None: + args.operands = [dst, src] + else: + args.operands = [dst if dst is not None else src] + + return args + def _convert_flags(self, args): ''' Transform user provided options to bit flags @@ -86,6 +118,14 @@ def _convert_flags(self, args): flags |= gadfinder.ALLOW_COMPLEX_MEM if not args.keep_canary_address: flags |= gadfinder.AVOID_CANARY + if args.ret_imm: + flags |= gadfinder.ALLOW_RET_IMM + if args.reg_aliases: + flags |= gadfinder.ALLOW_REG_ALIASES + if args.keep_contradictory: + flags |= gadfinder.KEEP_CONTRADICTORY + if not args.frame: + flags |= gadfinder.UNFRAMED namespace['flags'] = flags diff --git a/rop3/binaries/elf.py b/rop3/binaries/elf.py index e487fad..240b77a 100644 --- a/rop3/binaries/elf.py +++ b/rop3/binaries/elf.py @@ -15,7 +15,6 @@ along with rop3. If not, see . ''' -import capstone import io from elftools.elf.elffile import ELFFile, ELFError @@ -24,8 +23,13 @@ import rop3.binary as binary from rop3.archs.x86_arch import X86_Architecture, X64_Architecture +from rop3.archs.riscv_arch import RISCV_Architecture +from rop3.archs.aarch64_arch import AArch64_Architecture SHF_EXECINSTR = 0x4 +# RISC-V ELF e_flags: bit 0 marks the presence of the C (compressed) extension, +# which relaxes instruction alignment from 4 to 2 bytes (RISC-V psABI). +EF_RISCV_RVC = 0x1 class ELF: @@ -46,6 +50,15 @@ def _parse_arch(self): return X86_Architecture() elif self._elf.elfclass == 64: return X64_Architecture() + elif self._elf.header.e_machine in ['EM_RISCV']: + if self._elf.elfclass == 32: + raise NotImplementedError('ELF: RV32 is not supported yet') + elif self._elf.elfclass == 64: + compressed = bool(self._elf.header.e_flags & EF_RISCV_RVC) + return RISCV_Architecture(compressed=compressed) + elif self._elf.header.e_machine in ['EM_AARCH64']: + if self._elf.elfclass == 64: + return AArch64_Architecture() raise binary.BinaryException( 'ELF: Unsupported architecture type') @@ -70,11 +83,24 @@ def get_exec_sections(self): ''' SHF_EXECINSTR means section contains executable code ''' if sec.header.sh_flags & SHF_EXECINSTR: ret.append({ + 'name': sec.name, 'vaddr': sec.header.sh_addr + self._base_delta, 'opcodes': sec.data() }) return ret + def get_info(self): + ''' Format-level metadata for verbose reporting. ''' + h = self._elf.header + return { + 'format': f'ELF{self._elf.elfclass}', + 'endianness': 'little' if self._elf.little_endian else 'big', + 'machine': h.e_machine, + 'type': h.e_type, + 'entry': h.e_entry + self._base_delta, + 'image_base': self._image_base() + self._base_delta, + } + def get_symbols(self): ''' Function/object symbols from .symtab and .dynsym, rebased by the same delta as the sections. Stripped binaries yield none. ''' diff --git a/rop3/binaries/macho.py b/rop3/binaries/macho.py index 1dee7e4..4a4435f 100644 --- a/rop3/binaries/macho.py +++ b/rop3/binaries/macho.py @@ -29,16 +29,26 @@ import rop3.binary as binary from rop3.archs.x86_arch import X86_Architecture, X64_Architecture +from rop3.archs.aarch64_arch import AArch64_Architecture VM_PROT_EXECUTE = 0x04 S_INSTRUCTION_ATTRS = S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS # Mach-O architecture name -> (rop3 architecture class) +# Keyed by the lowercased macholib CPU_TYPE_NAMES value (which spells arm64 +# 'ARM64'); an arm64e slice shares the ARM64 cputype, so it maps here too. SUPPORTED_ARCHS = { 'x86_64': X64_Architecture, 'i386': X86_Architecture, + 'arm64': AArch64_Architecture, } + +def _arch_name(cputype): + ''' Lowercased architecture name for a Mach-O cputype (None if unknown). ''' + name = CPU_TYPE_NAMES.get(cputype) + return name.lower() if name is not None else None + class MachO: def __init__(self, data, base, arch=None): # Dirty way to initialize the class, since it only supports reading @@ -66,7 +76,7 @@ def _select_slice(self, arch): ''' available = {} # arch name -> header (first occurrence) for header in self._macho.headers: - name = CPU_TYPE_NAMES.get(header.header.cputype) + name = _arch_name(header.header.cputype) if name is not None: available.setdefault(name, header) @@ -82,7 +92,7 @@ def _select_slice(self, arch): return available[arch], SUPPORTED_ARCHS[arch]() for header in self._macho.headers: - name = CPU_TYPE_NAMES.get(header.header.cputype) + name = _arch_name(header.header.cputype) if name in SUPPORTED_ARCHS: return header, SUPPORTED_ARCHS[name]() @@ -132,7 +142,7 @@ def get_symbols(self): as the sections. macholib does not expand the nlist array, so it is parsed here from the file. Stripped binaries yield none. ''' ret = [] - is64 = self._arch.mode == capstone.CS_MODE_64 + is64 = self._arch.address_size == 8 entry_fmt = ' bool: + ''' Whether INFO-level (--verbose) messages are enabled. Lets callers skip + building trace strings that would be discarded when verbosity is off. ''' + return logger.isEnabledFor(logging.INFO) + def debug(msg): log(logging.DEBUG, msg) diff --git a/rop3/gadfinder.py b/rop3/gadfinder.py index 02aadc5..5ca1ce0 100644 --- a/rop3/gadfinder.py +++ b/rop3/gadfinder.py @@ -16,26 +16,28 @@ ''' import os -import re import math import bisect import capstone import multiprocessing +from itertools import product, count from rop3.cache import GadgetCache import rop3.utils as utils import rop3.debug as debug import rop3.binary -import rop3.operation as operation -from rop3.arch import arch_singleton +from rop3.operation import OperationDef, match_gadgets, realize +from rop3.arch import arch_singleton, DEFAULT_DEPTH from rop3.archs.x86_arch import X86_Architecture, X64_Architecture -from rop3.ropchain import RopChain +from rop3.archs.riscv_arch import RISCV_Architecture import rop3.parser as parser from .gadget import Gadget -''' Default depth engine ''' -DEPTH = 5 +''' Default (x86) search depth in bytes; kept for backward compatibility. + The effective default is architecture-specific (Architecture.default_depth, + used when --depth is omitted). ''' +DEPTH = DEFAULT_DEPTH ''' Flags when searching gadgets ''' DEFAULT = 0 @@ -46,17 +48,26 @@ ALLOW_UNDETERMINISTIC = 16 ALLOW_COMPLEX_MEM = 32 AVOID_CANARY = 64 +ALLOW_RET_IMM = 128 +ALLOW_REG_ALIASES = 256 +KEEP_CONTRADICTORY = 512 +UNFRAMED = 1024 ''' Terminator canary bytes to avoid in gadget addresses by default: 0x00 (string terminator for strcpy() and alike), 0x0a and 0x0d (line terminators for gets() and alike) and 0xff (EOF). See issue #5. ''' CANARY_BYTES = (0x00, 0x0a, 0x0d, 0xff) +# Base for the fresh generic register slots that stand in for an operation's +# unbound operands (see GadFinder.bind_step). Kept far above any REGn a ROPLang +# definition uses so the two never collide. +_FRESH_SLOT_BASE = 9000000 + class GadFinder: ''' Class to search gadgets in a binary ''' - def __init__(self, depth=DEPTH, flags=DEFAULT, cache=False, cache_dir=None, + def __init__(self, depth=None, flags=DEFAULT, cache=False, cache_dir=None, jobs=1): self.depth = depth self.flags = flags @@ -114,7 +125,7 @@ def _avoid_bytes(self, badchars) -> set: def _addr_canary_score(self, gadget: Gadget, avoid: set) -> int: ''' Number of bytes to avoid present in the gadget's packed address ''' - packed = utils.pack_addr(gadget.vaddr, gadget.mode) + packed = utils.pack_addr(gadget.vaddr, arch_singleton.arch.address_size) return sum(byte in avoid for byte in packed) def _sort_gadgets(self, gadgets: list[Gadget]) -> list[Gadget]: @@ -126,6 +137,7 @@ def _open_binary(self, filename, base, arch=None): if arch_singleton.is_initialized() and not arch_singleton.matches(binary_arch): debug.error(f'{filename}: mixing architectures (x86/x64) in a single run is not supported') arch_singleton.initialize(binary_arch) + arch_singleton.allow_reg_aliases = bool(self._allow_reg_aliases()) return binary def _symbol_table(self, binary): @@ -145,25 +157,153 @@ def _nearest_symbol(self, vaddr, symbol_table): offset = vaddr - sym_addr return f'{name}+{hex(offset)}' if offset else name - def find_op(self, filenames, op, dst=None, src=None, base=None, + def find_op(self, filenames, op, operands=None, base=None, badchars=None, badchar_bytes=None, arch=None, symbols=False): gadgets = self.find(filenames, base, badchars, badchar_bytes, arch, symbols) - return self.find_op_from_gadgets(gadgets, op, dst, src) + return self.find_op_from_gadgets(gadgets, op, operands) - def find_op_from_gadgets(self, gadgets, op, dst=None, src=None): + def find_op_from_gadgets(self, gadgets, op, operands=None): from rop3.ropchain import RopChain, RopChainNotFound resolved = parser.Parser().get_op(op) - if isinstance(resolved, parser.CompositeOperation): - ropchain = RopChain(self).expand_steps(resolved.steps, dst, src) + operands = list(operands) if operands else [] + step = {'op': op, 'operands': operands, + 'data': f'{op}({", ".join(operands)})'} + + # Operations realized as multi-step chains (containing operation refs or + # more than one gadget) expand into ROP chains; purely single-gadget + # operations return a flat list of matching gadgets. + has_chain = any(not real.is_single_gadget for real in resolved.realizations) + if has_chain: try: - return list(RopChain(self).search(gadgets, ropchain, prune_equivalent=False)) + return list(RopChain(self).search(gadgets, [step], prune_equivalent=False)) except RopChainNotFound: return [] - op_obj = operation.Operation(op, dst, src) - return op_obj.filter_gadgets(gadgets) + # Operands are positional: op1, op2, op3, ... + return self.match_operation(gadgets, resolved, operands, + reject_clobbered=not self._keep_contradictory()) + + # --- ROP-chain classification ----------------------------------------- + + def classify_ropchain(self, gadgets, steps): + ''' + Resolve a parsed ROP-chain request into the gadgets that realize it. + + `steps` is the parsed request: a list of {'op', 'operands', 'data'} + dicts. Each step is bound and expanded into its alternative primitive + chains (a compound operation has several); the cartesian product across + steps enumerates the candidate realizations, and every 2-operand + primitive of a realization is matched against `gadgets`. + + Returns a list of realizations, each a list of (primitive_step, gadgets) + pairs -- the per-step classified gadgets the assembler consumes. + Realizations in which some primitive matches no gadget are dropped. + ''' + fresh = count() # source of fresh generic slots for unbound operands + per_step_alternatives = [] + for step in steps: + binding = self.bind_step(step, fresh) + alternatives = self.expand_operation(step['op'], binding) + if not alternatives: + from rop3.ropchain import RopChainNotFound + raise RopChainNotFound( + f'{step.get("data", step["op"])}: no realization for operation') + per_step_alternatives.append(alternatives) + + realizations = [] + for combo in product(*per_step_alternatives): + primitives = [prim for chain in combo for prim in chain] + bundle = self._match_primitives(gadgets, primitives) + if bundle is not None: + realizations.append(bundle) + return realizations + + def _match_primitives(self, gadgets, primitives): + ''' Match each primitive step against `gadgets`, returning a list of + (step, gadgets) pairs. None if any primitive matches no gadget (the + realization is infeasible and is skipped). ''' + bundle = [] + for prim in primitives: + operands = [self._resolve_operand(prim.get('op1')), + self._resolve_operand(prim.get('op2'))] + gads = self.match_operation(gadgets, prim['defn'], operands) + if not gads: + debug.info(f'{prim["data"]}: no matching gadgets') + return None + debug.info(f'{prim["data"]}: {len(gads)} matching gadgets') + bundle.append((prim, gads)) + return bundle + + def _resolve_operand(self, val): + ''' Map a primitive operand to a match value: REG_SP/REG_BP -> the arch + pointer register, a generic REGn slot -> None (matches any register), + anything else -> itself. ''' + if val is None: + return None + aliased = self.resolve_reg_alias(val) # REG_SP/REG_BP -> sp/bp + if aliased != val: + return aliased + if isinstance(val, str) and val.lower().startswith('reg'): + return None + return val + + def match_operation(self, gadgets: list[Gadget], defn: OperationDef, + operands, reject_clobbered: bool = True) -> list[Gadget]: + ''' Gadgets realizing operation definition `defn` with the given + positional operands. The single entry point for operation matching; + the ROPLang name is resolved to `defn` here in gadfinder (via the + parser), keeping the matcher free of name lookups. ''' + return match_gadgets(defn, operands, gadgets, reject_clobbered=reject_clobbered) + + def bind_step(self, step, fresh): + ''' + Resolve a requested chain step's operands to values, so a compound + operation can be searched as a single operation with unbound (None) + operands. Operands the user gave (positionally: an `operands` list or the + op1/op2 keys) become concrete registers; any unbound operand becomes a + fresh generic register slot drawn from `fresh` (a shared counter). So the + expanded steps -- and thus ropchain construction -- only ever contain + concrete registers and generic (REGn) slots, never the operation's opN + names, regardless of how many operands it has. + + Raises parser.ParserException if the operation is undefined. + ''' + defn = parser.Parser().get_op(step['op']) + operands = step.get('operands') + if operands is None: + operands = [step.get('op1'), step.get('op2')] + binding = {} + for i in range(defn.operands): + value = operands[i] if i < len(operands) else None + if value is None: + value = f'REG{_FRESH_SLOT_BASE + next(fresh)}' + binding[f'op{i + 1}'] = value + return binding + + def expand_operation(self, op: str, binding: dict) -> list[list[dict]]: + ''' Flatten a ROPLang operation (named `op`) into its alternative + 2-operand primitive step chains. The name is resolved to its + definition here (via the parser); an undefined operation surfaces as + RopChainNotFound, the assembler's own error type. ''' + from rop3.ropchain import RopChainNotFound + try: + return realize(parser.Parser().get_op(op), binding) + except parser.ParserException as exc: + raise RopChainNotFound(str(exc)) + + def is_abstract_reg(self, reg): + ''' Whether `reg` may fill an abstract operand slot (a canonical-width + register for the scanned architecture). ''' + return arch_singleton.arch.is_valid_abstract_reg(reg) + + def resolve_reg_alias(self, name): + ''' Map the ROPLang stack/base-pointer aliases (REG_SP/REG_BP) to the + architecture's concrete pointer registers; any other name passes + through unchanged. ''' + arch = arch_singleton.arch + return {'REG_SP': arch.sp, 'REG_BP': arch.bp}.get(name, name) def _search_gadgets(self, binary, badchars, badchar_bytes=None, symbol_table=None): ''' @@ -172,6 +312,11 @@ def _search_gadgets(self, binary, badchars, badchar_bytes=None, symbol_table=Non stores the raw (vaddr, bytes) records; everything address/disassembly derived (decodes, symbol) is rebuilt here. ''' + # `depth is None` means "architecture default"; the arch is initialized + # by the time any binary is scanned, so resolve it here. + if self.depth is None: + self.depth = arch_singleton.arch.default_depth + key = None if self._cache is not None: key = self._cache.key( @@ -184,17 +329,25 @@ def _search_gadgets(self, binary, badchars, badchar_bytes=None, symbol_table=Non yield from self._reconstruct(binary, cached, symbol_table) return - if self._jobs > 1: + ''' The parallel scanner chunks by termination byte-offset, which only + the Galileo backward walk supports; other strategies (the linear + sweep) run single-threaded. ''' + parallelizable = arch_singleton.arch.parallelizable + if self._jobs > 1 and parallelizable: records = self._scan_parallel(binary, badchars, badchar_bytes) if self._cache is not None: self._cache.store(key, records) yield from self._reconstruct(binary, records, symbol_table) return + if self._jobs > 1 and not parallelizable: + debug.info(f'{arch_singleton.arch.scan_name} scan runs ' + f'single-threaded; --jobs ignored') + records = [] if self._cache is not None else None arch = arch_singleton.arch.arch mode = arch_singleton.arch.mode - for vaddr, raw, decodes in self._scan(binary, badchars, badchar_bytes): + for vaddr, raw, decodes in self._scan_sections(binary, badchars, badchar_bytes): if records is not None: records.append([vaddr, raw.hex()]) symbol = self._nearest_symbol(vaddr, symbol_table) if symbol_table else None @@ -241,36 +394,30 @@ def _scan_parallel(self, binary, badchars, badchar_bytes): records.sort() # deterministic order regardless of worker scheduling return records - def _scan(self, binary, badchars, badchar_bytes): - ''' Single pass over the executable sections; yields the raw - (vaddr, bytes, decodes) of every valid gadget (one disassembly). ''' - sections = binary.get_exec_sections() - arch = arch_singleton.arch.arch - mode = arch_singleton.arch.mode - - gad_terminations = self._gad_terminations() + def _scan_sections(self, binary, badchars, badchar_bytes): + ''' Single pass over the executable sections, delegating to the + architecture's own gadget scan (Architecture.scan); yields the raw + (vaddr, bytes, decodes) of every valid gadget. Each architecture + wires the search strategy that fits its ISA, so there is no + per-strategy branching here. ''' + arch_obj = arch_singleton.arch + # Byte-pattern terminations drive the Galileo backward walk; the linear + # sweeps find terminations by disassembly and ignore them. + terminations = self._gad_terminations() - md = capstone.Cs(arch, mode) + md = capstone.Cs(arch_obj.arch, arch_obj.mode) md.detail = True - for termination in gad_terminations: - for section in sections: - sec_opcodes = section['opcodes'] - sec_vaddr = section['vaddr'] - ''' Iterate all references to gadget termination ''' - for match in re.finditer(termination['bytes'], sec_opcodes): - ref = match.end() - ''' Search backwards from reference ''' - for depth in range(termination['size'], self.depth + 1): - ''' Virtual address inside section ''' - vaddr = sec_vaddr + ref - depth - if self._is_valid_address(vaddr, badchars, mode): - bytes_ = sec_opcodes[ref - depth:ref] - if not self._is_valid_bytes(bytes_, badchar_bytes): - continue - decodes = list(md.disasm(bytes_, vaddr)) - if self._is_valid_gadget(decodes): - yield (vaddr, bytes_, decodes) + def accept_candidate(vaddr, raw): + return (self._is_valid_address(vaddr, badchars, arch_obj.address_size) + and self._is_valid_bytes(raw, badchar_bytes)) + + for section in binary.get_exec_sections(): + opcodes, vaddr = section['opcodes'], section['vaddr'] + yield from arch_obj.scan( + opcodes, vaddr, self.depth, md.disasm, self._is_valid_gadget, + terminations=terminations, accept_candidate=accept_candidate, + framed=self._framed()) def _reconstruct(self, binary, records, symbol_table): ''' Rebuild Gadget objects from cached (vaddr, hex-bytes) records. ''' @@ -304,10 +451,11 @@ def _gad_terminations(self): arch = arch_singleton.arch + ret_imm = bool(self._allow_ret_imm()) if self._rop(): - ret.extend(arch.get_rop_terminations()) + ret.extend(arch.get_rop_terminations(include_ret_imm=ret_imm)) if self._retf(): - ret.extend(arch.get_rop_terminations(include_extra=True)) + ret.extend(arch.get_rop_terminations(include_retf=True, include_ret_imm=ret_imm)) if self._jop(): ret.extend(arch.get_jop_terminations()) @@ -334,6 +482,19 @@ def _keep_duplicates(self): def _avoid_canary(self): return self.flags & AVOID_CANARY + def _allow_ret_imm(self): + return self.flags & ALLOW_RET_IMM + + def _allow_reg_aliases(self): + return self.flags & ALLOW_REG_ALIASES + + def _keep_contradictory(self): + return self.flags & KEEP_CONTRADICTORY + + def _framed(self): + ''' Framed search is the default; UNFRAMED disables it. ''' + return not (self.flags & UNFRAMED) + def _is_valid_gadget(self, decodes): ''' Invalid instructions and, thus, not decoded ''' if not decodes: @@ -342,10 +503,11 @@ def _is_valid_gadget(self, decodes): ret = False arch = arch_singleton.arch allow_undeterministic = bool(self._allow_undeterministic()) + allow_ret_imm = bool(self._allow_ret_imm()) if self._rop(): - ret |= arch.is_valid_rop_gadget(decodes, allow_undeterministic=allow_undeterministic) + ret |= arch.is_valid_rop_gadget(decodes, allow_undeterministic=allow_undeterministic, allow_ret_imm=allow_ret_imm) if self._retf(): - ret |= arch.is_valid_rop_gadget(decodes, include_extra=True, allow_undeterministic=allow_undeterministic) + ret |= arch.is_valid_rop_gadget(decodes, include_retf=True, allow_undeterministic=allow_undeterministic, allow_ret_imm=allow_ret_imm) if not ret and self._jop(): ret |= arch.is_valid_jop_gadget(decodes, allow_undeterministic=allow_undeterministic) @@ -355,11 +517,11 @@ def _is_valid_gadget(self, decodes): return ret - def _is_valid_address(self, vaddr, badchars, arch_mode): + def _is_valid_address(self, vaddr, badchars, address_size): if not badchars: return True - vaddr = utils.pack_addr(vaddr, arch_mode) + vaddr = utils.pack_addr(vaddr, address_size) return not any([bytes([int(badchar, 0)]) in vaddr for badchar in badchars]) @@ -374,6 +536,8 @@ def _is_valid_bytes(self, gadget_bytes, badchar_bytes): def _arch_for(arch_const, mode): ''' Rebuild the architecture object inside a worker process. ''' + if arch_const == capstone.CS_ARCH_RISCV: + return RISCV_Architecture(compressed=bool(mode & capstone.CS_MODE_RISCVC)) return X64_Architecture() if mode == capstone.CS_MODE_64 else X86_Architecture() @@ -387,32 +551,29 @@ def _scan_worker(task): (arch_const, mode, depth, flags, terminations, badchars, badchar_bytes, slice_bytes, slice_start, sec_vaddr, emit_lo, emit_hi) = task + arch_obj = _arch_for(arch_const, mode) arch_singleton.reset() - arch_singleton.initialize(_arch_for(arch_const, mode)) + arch_singleton.initialize(arch_obj) finder = GadFinder(depth, flags) md = capstone.Cs(arch_const, mode) md.detail = True + def accept_match(ref): + ''' Only this chunk owns terminations ending in [emit_lo, emit_hi). ''' + return emit_lo <= slice_start + ref < emit_hi + + def accept_candidate(vaddr, raw): + return (finder._is_valid_address(vaddr, badchars, arch_obj.address_size) + and finder._is_valid_bytes(raw, badchar_bytes)) + + # The slice starts `slice_start` bytes into the section. + base_vaddr = sec_vaddr + slice_start out = [] - for termination in terminations: - for match in re.finditer(termination['bytes'], slice_bytes): - ref_local = match.end() - ref_off = slice_start + ref_local # offset within the section - ''' Only this chunk owns terminations in [emit_lo, emit_hi) ''' - if not (emit_lo <= ref_off < emit_hi): - continue - for d in range(termination['size'], depth + 1): - start_local = ref_local - d - if start_local < 0: - continue - vaddr = sec_vaddr + ref_off - d - if finder._is_valid_address(vaddr, badchars, mode): - raw = slice_bytes[start_local:ref_local] - if not finder._is_valid_bytes(raw, badchar_bytes): - continue - decodes = list(md.disasm(raw, vaddr)) - if finder._is_valid_gadget(decodes): - out.append([vaddr, raw.hex()]) + for vaddr, raw, _decodes in arch_obj.scan( + slice_bytes, base_vaddr, depth, md.disasm, finder._is_valid_gadget, + terminations=terminations, accept_candidate=accept_candidate, + accept_match=accept_match, framed=finder._framed()): + out.append([vaddr, raw.hex()]) return out diff --git a/rop3/gadget.py b/rop3/gadget.py index 3a09a6b..07a6914 100644 --- a/rop3/gadget.py +++ b/rop3/gadget.py @@ -43,51 +43,114 @@ class Gadget: bytes: str = None count: int = None op: str = None - dst: str = None - src: str = None + dst: set = None # concrete register names written (may overlap src) + src: set = None # concrete register names read (may overlap dst) symbol: str = None side_regs: set[str] = field(init=False, default_factory=set) - side_mem: set[str] = field(init=False, default_factory=set) + # Concrete registers bound to the operation's two operand slots. + slot_op1: str = field(init=False, default=None) + slot_op2: str = field(init=False, default=None) + # Display form of the same two operand slot. + disp_op1: str = field(init=False, default=None) + disp_op2: str = field(init=False, default=None) def __post_init__(self): self.text_repr = ' ; '.join([f'{d.mnemonic} {d.op_str}' if d.op_str else \ d.mnemonic for d in self.decodes]) - def has_dst(self) -> bool: - return self.dst is not None - - def has_src(self) -> bool: - return self.src is not None - def calculate_side_effects(self) -> None: arch = arch_singleton.arch - excluded = {arch.normalize_reg(r) for r in (self.dst, arch.sp) if r is not None} + excluded = {arch.normalize_reg(arch.sp)} + excluded |= {arch.normalize_reg(r) for r in (self.dst or ())} for decode in self.decodes: - explicit = {decode.reg_name(r) for r in decode.regs_write} - _, implicit_ids = decode.regs_access() - implicit = {decode.reg_name(r) for r in implicit_ids} - for reg in explicit | implicit: - normalized = arch.normalize_reg(reg) + for reg in arch.written_registers(decode): + normalized = arch.normalize_reg(decode.reg_name(reg)) if normalized not in excluded: self.side_regs.add(normalized) + def _register_set(self, accessor) -> set[str]: + ''' Normalized registers accessed by the whole gadget via `accessor` + (arch.written_registers / arch.read_registers), excluding the stack + pointer (every ret/pop touches it, so it is noise). ''' + arch = arch_singleton.arch + sp = arch.normalize_reg(arch.sp) + regs = set() + for decode in self.decodes: + for reg in accessor(decode): + normalized = arch.normalize_reg(decode.reg_name(reg)) + if normalized != sp: + regs.add(normalized) + return regs + + def tuple_repr(self) -> str: + ''' Formal tuple representation of the gadget: + ''' + arch = arch_singleton.arch + written = self._register_set(arch.written_registers) + read = self._register_set(arch.read_registers) + + parts = [self.op or ''] + for operand in (self.disp_op1, self.disp_op2): + if operand is not None: + parts.append(str(operand)) + parts.append('{' + ', '.join(sorted(written)) + '}') + parts.append('{' + ', '.join(sorted(read)) + '}') + return '\u27e8' + ', '.join(parts) + '\u27e9' + def writes_reg(self, normalized_reg: str) -> bool: ''' Whether the gadget explicitly or implicitly writes normalized_reg. ''' arch = arch_singleton.arch for decode in self.decodes: - explicit = {decode.reg_name(r) for r in decode.regs_write} - _, implicit_ids = decode.regs_access() - implicit = {decode.reg_name(r) for r in implicit_ids} - for reg in explicit | implicit: - if arch.normalize_reg(reg) == normalized_reg: + for reg in arch.written_registers(decode): + if arch.normalize_reg(decode.reg_name(reg)) == normalized_reg: return True return False + def result_clobbered(self, matched_indices, dst_regs) -> bool: + ''' Whether this gadget overwrites an operation's result before its + terminator -- a "contradictory" gadget (e.g. + `add rax, rbx ; mov rax, rcx ; ret`) whose result never reaches the + ret. `matched_indices` are the positions of the operation's matched + instructions and `dst_regs` its declared destination registers. + + `dst_regs` are intersected with the registers the matched + instructions actually write, so a store (whose result is in memory) + protects nothing and is never falsely rejected. A gadget is + contradictory when an instruction between the last matched one and + the terminator writes such a register. + + The final (terminating) instruction is excluded: it is control flow, + and its incidental write to the stack pointer (an x86 `ret` pops) is + the gadget's exit mechanism, not a clobber of the result -- so a + stack-pointer operation like `add rsp, 8 ; ret` is not + contradictory. + + `matched_indices` are contiguous (Set.is_equal matches a consecutive + run), so only the tail after `max(matched_indices)` needs scanning; + a clobber can never hide between two matched instructions. ''' + if not dst_regs: + return False + + arch = arch_singleton.arch + + def writes(insn): + return {arch.normalize_reg(insn.reg_name(r)) + for r in arch.written_registers(insn)} + + produced = {reg for i in matched_indices for reg in writes(self.decodes[i])} + guarded = set(dst_regs) & produced + if not guarded: + return False + + last = max(matched_indices) + clobbered = {reg for insn in self.decodes[last + 1:-1] for reg in writes(insn)} + return bool(guarded & clobbered) + def subsumes(self, rhs) -> bool: - if str(self.dst) != str(rhs.dst): + if (self.dst or set()) != (rhs.dst or set()): return False - if str(self.src) != str(rhs.src): + if (self.src or set()) != (rhs.src or set()): return False if self.side_regs.issubset(rhs.side_regs): return True @@ -108,8 +171,6 @@ def __repr__(self) -> str: ret += f" (src = {self.src})" if self.side_regs: ret += f" (side regs = {self.side_regs})" - if self.side_mem: - ret += f" (side mem = {self.side_mem})" ret += f" (count: {self.count})" return ret @@ -141,8 +202,8 @@ def to_dict(self) -> dict: 'count': self.count, 'symbol': self.symbol, 'op': self.op, - 'dst': self.dst, - 'src': self.src, + 'dst': sorted(self.dst) if self.dst else None, + 'src': sorted(self.src) if self.src else None, 'modifies': sorted(self.side_regs), } diff --git a/rop3/interactive.py b/rop3/interactive.py index 4c97cb0..1f2bd44 100644 --- a/rop3/interactive.py +++ b/rop3/interactive.py @@ -57,16 +57,15 @@ def do_count(self, arg): print(len(self.rop3.gadgets())) def do_op(self, arg): - 'op [dst] [src]: search for an operation' + 'op [operands...]: search for an operation' parts = shlex.split(arg) if not parts: - print('usage: op [dst] [src]') + print('usage: op [operands...]') return op = parts[0] - dst = parts[1] if len(parts) > 1 else None - src = parts[2] if len(parts) > 2 else None + operands = parts[1:] or None try: - result = self.rop3.find_op(op, dst, src) + result = self.rop3.find_op(op, operands=operands) except parser.ParserException as exc: print(str(exc)) return diff --git a/rop3/operation.py b/rop3/operation.py index 64b1c16..599f41a 100644 --- a/rop3/operation.py +++ b/rop3/operation.py @@ -15,292 +15,643 @@ along with rop3. If not, see . ''' -import capstone +from __future__ import annotations + +import re +import copy import dataclasses +from itertools import product from rop3.arch import arch_singleton +import rop3.debug as debug import rop3.parser as parser from .gadget import Gadget -class Operation: - def __init__(self, op, dst=None, src=None): - self.name = op - self.template = parser.Parser().get_op(op) - self.dst = dst - self.template.set_dst(dst) - self.src = src - self.template.set_src(src) +# Abstract operand placeholders: operation operands op1, op2, op3, ... and the +# scratch helper registers REG1, REG10, ... +_ABSTRACT_RE = re.compile(r'^(op\d+|REG\d+)$') - def filter_gadgets(self, gadgets) -> list[Gadget]: - ret = [] - if not gadgets: - return ret +def is_abstract_name(name) -> bool: + return isinstance(name, str) and bool(_ABSTRACT_RE.match(name)) - arch = gadgets[0].arch - mode = gadgets[0].mode - for gadget in gadgets: - (equal, set_, dst, src) = self.template.is_equal(gadget.decodes) - if equal: - ''' Annotate a copy so the shared input gadget is not mutated - (the same object may be filtered for several operations). - replace() also gives the copy fresh side-effect sets. ''' - matched = dataclasses.replace( - gadget, - op=self.template.name, - dst=self.dst if self.dst else dst, - src=self.src if self.src else src, - ) - matched.calculate_side_effects() - ret.append(matched) +def is_immediate(value) -> bool: + ''' Whether a value is a numeric immediate (e.g. 8, '8', '0x10', '#0', -1) + rather than a register name. ''' + if isinstance(value, int): + return True + try: + int(str(value).lstrip('#'), 0) + return True + except (ValueError, TypeError): + return False - return ret -class OperationTemplate: - def __init__(self, op): - self.name = op - self.sets = [] +# --- Operation matching --------------------------------------------------- +# +# Match single-gadget realizations of an operation against a gadget list. The +# operation's operands are given positionally as `operands` = [op1, op2, op3, +# ...] (an operation has 1, 2 or 3 of them); a value of None leaves that operand +# unconstrained (matches any register). Callers resolve a ROPLang name to its +# OperationDef (via the parser) before calling, so this module never does name +# lookups for matching. + +def match_gadgets(defn: OperationDef, operands: list | None, + gadgets: list[Gadget], reject_clobbered: bool = True) -> list[Gadget]: + ''' Gadgets whose instructions realize `defn` with the given positional + operands. The operation's instructions must be a consecutive run, the + first sitting right after the architecture's frame prologue + (Set.is_equal). + + With `reject_clobbered` (the default), a gadget is discarded when its + destination register is overwritten before the terminator (a + "contradictory" gadget that does not actually realize the operation), + via Gadget.result_clobbered -- before the (more expensive) annotation. ''' + if not defn.available: + reason = defn.unavailable_reason or 'not available for this architecture' + raise parser.OperationNotAvailable(f'{defn.name}: {reason}') + + bindings = _operand_bindings(operands) + ret: list[Gadget] = [] + if not gadgets: + return ret - def __iter__(self): - for item in self.sets: - yield item + for real in defn.realizations: + # Realizations must be single gadgets (no references) + if not real.is_single_gadget: + continue + set_ = real.links[0].bound(bindings) + for gadget in gadgets: + (equal, binds, indices) = set_.is_equal(gadget.decodes) + if not equal: + continue + if reject_clobbered and gadget.result_clobbered( + indices, _destination_registers(defn, bindings, binds)): + continue + ret.append(_annotate(defn, bindings, gadget, binds)) + + return ret + + +def _operand_bindings(operands: list | None) -> dict: + ''' Map the positional operands to op1/op2/... slots, dropping None + (unconstrained) operands. ''' + bindings: dict = {} + for i, value in enumerate(operands or ()): + if value is not None: + bindings[f'op{i + 1}'] = value + return bindings + + +def _as_register(bindings: dict, name, binds: dict): + ''' The concrete register bound to an operand name, following one level of + placeholder indirection (opN -> free REGn -> concrete). None if the + operand is an immediate or is unbound. ''' + arch = arch_singleton.arch + val = bindings.get(name, name) + if isinstance(val, str) and is_abstract_name(val): + val = binds.get(val, val) + if not isinstance(val, str) or is_abstract_name(val) or is_immediate(val): + return None + return arch.normalize_reg(val) + + +def _as_operand(bindings: dict, name, binds: dict): + ''' Display value bound to an operand name: a concrete register (like + _as_register) or, when the operand bound to a numeric literal, that + immediate formatted as a string. None if the operand is an unbound + placeholder or is unused. ''' + val = bindings.get(name, name) + if isinstance(val, str) and is_abstract_name(val): + val = binds.get(val, val) + if isinstance(val, str) and is_abstract_name(val): + return None # still an unbound placeholder + if is_immediate(val): + return _format_imm(val) + if isinstance(val, str): + return arch_singleton.arch.normalize_reg(val) + return None + + +def _format_imm(val) -> str: + ''' Render an immediate the way the disassembly does: small magnitudes in + decimal (e.g. -1), larger ones in hex (e.g. 0x1000). ''' + n = int(str(val).lstrip('#'), 0) if not isinstance(val, int) else val + return str(n) if -256 < n < 256 else hex(n) + + +def _destination_registers(defn: OperationDef, bindings: dict, binds: dict) -> set: + ''' The concrete destination register(s) the operation writes, under the + given match bindings. Handed to Gadget.result_clobbered to reject + gadgets that overwrite the result before returning. ''' + return {r for r in (_as_register(bindings, n, binds) for n in defn.dst_roles) if r} + + +def _annotate(defn: OperationDef, bindings: dict, gadget: Gadget, binds: dict) -> Gadget: + ''' Annotate a copy so the shared input gadget is not mutated. ''' + def as_register(name): + return _as_register(bindings, name, binds) + + matched = dataclasses.replace(gadget, op=defn.name) + # dst/src register sets come from the operation's role metadata (which + # operands it writes / reads); the two solver slots are just op1 and op2. + matched.dst = {r for r in map(as_register, defn.dst_roles) if r} + matched.src = {r for r in map(as_register, defn.src_roles) if r} + matched.slot_op1 = as_register('op1') + matched.slot_op2 = as_register('op2') + matched.disp_op1 = _as_operand(bindings, 'op1', binds) + matched.disp_op2 = _as_operand(bindings, 'op2', binds) + matched.calculate_side_effects() + return matched + + +# --- Operation realization ------------------------------------------------ +# +# Operations are *defined* with N named operands (opN), but a ROP chain is +# *constructed* only from 2-operand primitives. `realize` resolves an operation +# into a flat list of 2-operand primitive steps: +# +# - a "primitive" operation (all realizations are single gadgets) becomes one +# step referencing that operation; its alternative single-gadget +# realizations are matched later by match_gadgets; +# - a "compound" operation is flattened by walking its realization's links, +# recursing into operation references and emitting inline raw-gadget links +# (e.g. the `leave`/`adc` mnemonics) as synthetic single-gadget primitives. +# +# It lives here (not in ropchain.py) because it works entirely on the operation +# definition structures below; the assembler reaches it via GadFinder. + +def _is_primitive(defn) -> bool: + return bool(defn.realizations) and all(r.is_single_gadget for r in defn.realizations) + + +def _operand_names(set_) -> list: + ''' Abstract operand names appearing in a gadget-pattern, in order. ''' + names = [] + for ins in set_.items: + for op in ins.operands: + if op.abstract and op.reg not in names: + names.append(op.reg) + return names + + +def _inline_operation_def(set_): + ''' + Wrap an inline raw-gadget link (a Set of mnemonics used directly inside a + compound, e.g. `leave` or `adc op1, REG1`) as a synthetic single-gadget + operation with positional operands op1, op2, ...: operand 0 is the + destination, all operands count as sources (accumulator-safe). Its operands + are renamed to op1/op2/... so it matches like any other 2-operand primitive. + + A Set may also declare extra implicit registers via `extra_writes` / + `extra_reads` (concrete names such as 'rflags'). + + Returns (defn, original_names), the original operand names in position order. + ''' + names = _operand_names(set_) + rename = {orig: f'op{i + 1}' for i, orig in enumerate(names)} + positional = list(rename.values()) + renamed = set_.renamed(rename) + mnemonic = renamed.items[0].mnemonic if renamed.items else 'inline' + dst_roles = positional[:1] + list(getattr(set_, 'extra_writes', None) or []) + src_roles = positional + list(getattr(set_, 'extra_reads', None) or []) + defn = OperationDef(mnemonic, operands=len(positional), + dst_roles=dst_roles, src_roles=src_roles) + real = Realization() + real.add(renamed) + defn.add(real) + return defn, names + + +def _primary_operands(defn, binding): + ''' The two operand-slot values of a primitive under `binding`: the primary + destination operand (op1) and the primary non-accumulator source operand + (op2). Unbound operands are None (matches any register). ''' + op1 = binding.get(defn.dst_roles[0]) if defn.dst_roles else None + op2_name = next((r for r in defn.src_roles if r not in defn.dst_roles), None) + op2 = binding.get(op2_name) if op2_name is not None else None + return op1, op2 + + +def _format(op, op1, op2) -> str: + inside = '' if op1 is None else str(op1) + if op2 is not None: + inside += f', {op2}' + return f'{op}({inside})' + + +def _resolve_ref(name: str) -> OperationDef: + ''' Resolve a referenced operation name to its definition during + realization, raising a clear error for the recursive case. Realization + is a traversal of the operation catalog by name, so it consults the + parser here. ''' + try: + return parser.Parser().get_op(name) + except parser.ParserException as exc: + raise parser.ParserException(f'{name}: undefined operation referenced') from exc + + +def realize(defn: OperationDef, binding: dict, _depth: int = 0) -> list[list[dict]]: + ''' + Realize an operation definition into its alternative realizations, each a + flat list of 2-operand primitive steps. A compound operation yields one + chain per realization, and one per combination of its operation references' + own alternatives (cartesian product): every possibility is a distinct ROP + chain. A primitive yields a single chain of one step (its single-gadget + realizations are matched later by match_gadgets). + + Nested operation references are resolved by name against the parser catalog; + a missing one raises parser.ParserException, which + GadFinder.expand_operation translates to RopChainNotFound. + + `_depth` is only for indenting the --verbose expansion trace and is set by + the recursive calls; callers pass the default. + ''' + op = defn.name + # The expansion trace is built only under --verbose; guarding on `verbose` + # keeps the f-strings and _describe_link/_fmt_binding calls off the hot path. + verbose = debug.is_verbose() + pad = ' ' * _depth # verbose-trace indentation for this recursion level + + if _is_primitive(defn): + op1, op2 = _primary_operands(defn, binding) + step = _format(op, op1, op2) + if verbose: + debug.info(f'{pad}expand {op}({_fmt_binding(binding)}): primitive -> {step}') + return [[{'data': step, 'op': op, 'defn': defn, + 'op1': op1, 'op2': op2}]] + + if verbose: + debug.info(f'{pad}expand {op}({_fmt_binding(binding)}): compound, ' + f'{len(defn.realizations)} realization(s)') + chains: list[list[dict]] = [] + for ridx, real in enumerate(defn.realizations): + if verbose: + debug.info(f'{pad} realization #{ridx}: ' + f'[{" ; ".join(_describe_link(link) for link in real.links)}]') + # Each link contributes a list of alternative sub-chains; the cartesian + # product over the links yields this realization's chains. + link_alternatives = [] + for link in real.links: + if isinstance(link, OpRef): + sub_binding = {slot: binding.get(expr, expr) + for slot, expr in link.bindings.items()} + link_alternatives.append( + realize(_resolve_ref(link.name), sub_binding, _depth + 2)) + else: # inline Set + syn, names = _inline_operation_def(link) + # Step operand values are the resolved original operands, in the + # same positional order as the synthetic op's op1/op2. + values = [binding.get(name, name) for name in names] + op1 = values[0] if len(values) > 0 else None + op2 = values[1] if len(values) > 1 else None + inline_step = _format(syn.name, op1, op2) + if verbose: + debug.info(f'{pad} inline gadget -> {inline_step}') + link_alternatives.append([[{'data': inline_step, + 'op': syn.name, 'defn': syn, + 'op1': op1, 'op2': op2}]]) + if any(not alt for alt in link_alternatives): + if verbose: + debug.info(f'{pad} realization #{ridx}: dropped ' + '(a link is not realizable on this architecture)') + continue # some link cannot be realized on this architecture + for combo in product(*link_alternatives): + chain = [step for part in combo for step in part] + if verbose: + debug.info(f'{pad} chain: {" ; ".join(s["data"] for s in chain)}') + chains.append(chain) + + if verbose: + debug.info(f'{pad}expand {op}: -> {len(chains)} chain(s)') + return chains + + +def _fmt_binding(binding: dict) -> str: + ''' Compact `slot=value` view of an operand binding for verbose traces. ''' + return ', '.join(f'{slot}={value}' for slot, value in binding.items()) + + +def _describe_link(link) -> str: + ''' One-line description of a realization link for the verbose trace: an + operation reference with its bindings, or an inline gadget's mnemonics. ''' + if isinstance(link, OpRef): + args = ', '.join(f'{slot}={expr}' for slot, expr in link.bindings.items()) + return f'{link.name}({args})' + return ' ; '.join(ins.mnemonic for ins in link.items) + + +class OperationDef: + ''' + Parsed definition of a ROPLang operation for the current architecture: its + operand arity, which operands it writes (dst_roles) / reads (src_roles) for + side-effect accounting, and the list of alternative realizations (each a + chain of gadget-patterns and operation references). + ''' + def __init__(self, name, operands=0, dst_roles=None, src_roles=None, + available=True, unavailable_reason=None): + self.name = name + self.operands = operands + self.dst_roles = list(dst_roles or []) + self.src_roles = list(src_roles or []) + self.realizations: list[Realization] = [] + # Whether this operation is realizable on the current architecture. + # A YAML `: {available: false}` marks it unavailable (see parser). + self.available = available + self.unavailable_reason = unavailable_reason + + def add(self, realization): + self.realizations.append(realization) + + def mark_unavailable(self, reason=None): + ''' Flag this operation as not realizable on the current architecture. ''' + self.available = False + self.unavailable_reason = reason + + def add_realization(self, links): + ''' + Append a realization built from neutral link data, so callers (the + format parsers) construct definitions through OperationDef alone and + never touch the internal Realization/Set/OpRef/Instruction/Operand + nodes. `links` is an ordered list; each link is either: + + {'gadget': [{'mnemonic': str, 'operands': [str, ...]}, ...], + 'writes': [...], 'reads': [...]} -- instructions of one gadget + {'opref': str, 'bindings': {slot: value, ...}} -- reuse another op + ''' + real = Realization() + for link in links: + if 'opref' in link: + real.add(OpRef(link['opref'], link.get('bindings') or {})) + continue + s = Set() + for insn in link['gadget']: + ins = Instruction(insn['mnemonic']) + for operand in insn.get('operands') or (): + ins.add(Operand(operand)) + s.add(ins) + s.extra_writes = list(link.get('writes') or []) + s.extra_reads = list(link.get('reads') or []) + real.add(s) + self.realizations.append(real) + + +class Realization: + ''' One alternative realization: an ordered chain of links, each either a + Set (a gadget-pattern of consecutive instructions) or an OpRef. ''' + def __init__(self): + self.links: list = [] - def add(self, set_): - self.sets.append(set_) + def add(self, link): + self.links.append(link) - def set_dst(self, dst): - if dst: - for set_ in self.sets: - set_.set_dst(dst) + @property + def is_single_gadget(self) -> bool: + return len(self.links) == 1 and isinstance(self.links[0], Set) - def set_src(self, src): - if src: - for set_ in self.sets: - set_.set_src(src) - def is_equal(self, decodes): - dst = None - src = None +class OpRef: + ''' A step that reuses another operation, binding its operands. ''' + def __init__(self, name, bindings): + self.name = name + self.bindings = dict(bindings) # sub-op operand -> outer operand/value - for set_ in self.sets: - (equal, dst, src) = set_.is_equal(decodes) - if equal: - return (True, set_, dst, src) - - return (False, None, dst, src) class Set: + ''' A gadget-pattern: consecutive instructions matched within one gadget. ''' def __init__(self): self.items = [] - - def __iter__(self): - for item in self.items: - yield item - - def __len__(self): - return len(self.items) + self.extra_writes: list = [] + self.extra_reads: list = [] def __str__(self): - return ' ; '.join([str(item) for item in self.items]) + return ' ; '.join(str(item) for item in self.items) def add(self, item): self.items.append(item) - def set_dst(self, dst): - if dst: - for item in self.items: - item.set_dst(dst) - - def set_src(self, src): - if src: - for item in self.items: - item.set_src(src) + def bound(self, bindings: dict) -> "Set": + ''' A copy with the given operand names bound to concrete values. Each + operand is bound once by its own name, so binding an operand to a + value that happens to be another operand's name cannot cascade + (e.g. {op1: op2, op2: op3} yields `op2, op3`, not `op3, op3`). ''' + clone = copy.deepcopy(self) + for item in clone.items: + for operand in item.operands: + name = operand.reg + if operand.abstract and name in bindings: + operand.set_binding(name, bindings[name]) + return clone + + def renamed(self, mapping: dict) -> "Set": + ''' A copy with abstract operand names remapped (e.g. REG1 -> op2). ''' + clone = copy.deepcopy(self) + for item in clone.items: + for operand in item.operands: + if operand.abstract and operand.reg in mapping: + operand.reg = mapping[operand.reg] + return clone def is_equal(self, decodes): - dst = None - src = None - - if len(decodes) < len(self.items): - return (False, dst, src) - - for i, item in enumerate(self.items): - if not isinstance(item, Instruction): - return (False, dst, src) - - (equal, ins_dst, ins_src) = item.is_equal(decodes[i]) + ''' + Match this pattern against a gadget's decoded instructions. + + The gadget is viewed as [frame prologue] [operation body] [epilogue]. + The architecture's frame prologue (Architecture.is_frame_prefix) -- a + leading run of framing instructions, e.g. the RISC-V `ld ra, off(sp)` + restore; empty on x86/AArch64 -- is skipped, and the operation's + instructions must then match a *consecutive* run starting right after it + (position 0 when the prologue is empty). This anchors detection to real + gadgets instead of matching an operation buried behind arbitrary leading + instructions, and requires the pattern instructions to be adjacent: + `push src ; pop dst` realizes `mov(dst, src)`, but + `push src ; nop ; pop dst` does not. Instructions after the run form the + epilogue. + + Returns (matched, bindings, indices); `indices` are the (contiguous) + positions of the matched pattern instructions, used by the caller + (Gadget.result_clobbered) to reject contradictory gadgets. + ''' + if not self.items: + return (True, {}, []) + + arch = arch_singleton.arch + start = 0 + while start < len(decodes) and arch.is_frame_prefix(decodes[start]): + start += 1 + + if len(decodes) - start < len(self.items): + return (False, {}, []) + + # The pattern matches a consecutive run anchored at `start`. + bindings: dict = {} + indices = [] + for offset, item in enumerate(self.items): + pos = start + offset + (equal, binds) = item.is_equal(decodes[pos]) if not equal: - return (False, dst, src) + return (False, {}, []) + for name, val in binds.items(): # fold in per-instruction bindings + if name in bindings and bindings[name] != val: + return (False, {}, []) # conflicting operand reassignment + bindings[name] = val + indices.append(pos) - if ins_dst is not None: - if dst is None: - dst = ins_dst - elif dst != ins_dst: - return (False, dst, src) - if ins_src is not None: - if src is None: - src = ins_src - elif src != ins_src: - return (False, dst, src) + return (True, bindings, indices) - return (True, dst, src) class Instruction: def __init__(self, mnemonic): self.mnemonic = mnemonic self.operands = [] - def __iter__(self): - for item in self.operands: - yield item - def __str__(self): - operands = ', '.join([str(operand) for operand in self.operands]) - + operands = ', '.join(str(operand) for operand in self.operands) return f'{self.mnemonic} {operands}' - + def add(self, operand): self.operands.append(operand) - def set_dst(self, dst): - if dst: - for operand in self.operands: - operand.set_dst(dst) + def set_binding(self, name, value): + for operand in self.operands: + operand.set_binding(name, value) - def set_src(self, src): - if src: - for operand in self.operands: - operand.set_src(src) - def is_equal(self, decode): - dst = None - src = None - if self.mnemonic != decode.mnemonic: - return (False, dst, src) - + return (False, {}) if len(self.operands) != len(decode.operands): - return (False, dst, src) + return (False, {}) + bindings: dict = {} for myoperand, operand in zip(self.operands, decode.operands): - (equal, reg) = myoperand.is_equal(decode, operand) + (equal, bind) = myoperand.is_equal(decode, operand) if not equal: - return (False, dst, src) - - dst = reg if not dst and myoperand.is_dst() else dst - src = reg if not src and myoperand.is_src() else src + return (False, {}) + if bind is not None: + name, val = bind + if name in bindings and bindings[name] != val: + return (False, {}) + bindings[name] = val + + return (True, bindings) - return (True, dst, src) class Operand: - def __init__(self, operand, value=None): - self.value = value - self.type = self._parse_type(operand) + ''' + A pattern operand. It is one of: + - a register (abstract placeholder like op1/REG1, or a concrete reg name), + - a memory reference [base] (abstract or concrete base), or + - an immediate (numeric, optionally written #NN as in ARM/RISC-V asm). + ''' + def __init__(self, operand): + self.mem = False + self.abstract = False + self.reg = None + self.imm = None + self._parse(operand) def __str__(self) -> str: - if self.is_reg(): - return self.reg - elif self.is_mem(): - return f"[{self.reg}]" - else: + if self.is_mem(): + return f'[{self.reg}]' + if self.is_imm(): return str(self.imm) + return str(self.reg) - def _parse_type(self, reg): - self.generic = False - reg_name = str(reg) - if reg_name.startswith('[') and reg_name.endswith(']'): - self.reg = reg[1:-1] - if self.reg in ('dst', 'src') or self.reg.startswith('REG'): - self.generic = True - return arch_singleton.arch.op_mem - - self.reg = reg - - if reg in ('dst', 'src') or reg_name.startswith('REG'): - self.generic = True - return arch_singleton.arch.op_reg - - try: - self.imm = self._parse_imm(reg) - return arch_singleton.arch.op_imm - except (ValueError, TypeError): - return arch_singleton.arch.op_reg - - def _parse_imm(self, reg): - if isinstance(reg, int): - return reg - return int(reg, 0) + def _parse(self, operand): + s = str(operand) + if s.startswith('[') and s.endswith(']'): + self.mem = True + s = s[1:-1] - def is_reg(self): - return self.type == arch_singleton.arch.op_reg + if not self.mem: + imm = self._try_imm(s) + if imm is not None: + self.imm = imm + return - def is_mem(self): - return self.type == arch_singleton.arch.op_mem - - def is_imm(self): - return self.type == arch_singleton.arch.op_imm - - def is_dst(self): - if self.reg is not None: - return self.reg == 'dst' - return False - - def set_dst(self, dst): - if self.is_dst(): - self.generic = False - if self.is_mem(): - self.reg = dst - else: - try: - self.imm = self._parse_imm(dst) - self.type = arch_singleton.arch.op_imm - except (ValueError, TypeError): - self.reg = dst - self.type = arch_singleton.arch.op_reg - - def is_src(self): - if self.reg is not None: - return self.reg == 'src' - return False + self.reg = s + self.abstract = is_abstract_name(s) - def set_src(self, src): - if self.is_src(): - self.generic = False - if self.is_mem(): - self.reg = src - else: - try: - self.imm = self._parse_imm(src) - self.type = arch_singleton.arch.op_imm - except (ValueError, TypeError): - self.reg = src - self.type = arch_singleton.arch.op_reg + def _try_imm(self, value): + try: + return self._parse_imm(value) + except (ValueError, TypeError): + return None + + def _parse_imm(self, value): + if isinstance(value, int): + return value + value = str(value) + if value.startswith('#'): + value = value[1:] + return int(value, 0) + + def is_reg(self) -> bool: + return not self.mem and self.imm is None + + def is_mem(self) -> bool: + return self.mem + + def is_imm(self) -> bool: + return not self.mem and self.imm is not None + + def set_binding(self, name, value): + ''' Bind this operand if it is the abstract placeholder `name`. + Binding to another placeholder (a free chain variable such as REG1) + keeps the operand abstract so the ROP-chain solver can resolve it. ''' + if not self.abstract or self.reg != name: + return + imm = None if self.mem else self._try_imm(value) + if imm is not None: + self.imm = imm + self.reg = None + self.abstract = False + else: + self.reg = str(value) + self.abstract = is_abstract_name(value) def is_equal(self, decode, operand): - operand_reg = None - - # Allows generic reg -> imm substitution (not for mem) - if self.generic and self.is_src() and self.is_reg() and operand.type == arch_singleton.arch.op_imm: - return (True, operand.value.imm) - - if self.type != operand.type: - return (False, operand_reg) - - if self.is_reg(): - operand_reg = decode.reg_name(operand.value.reg) - elif self.is_mem(): - operand_reg = decode.reg_name(operand.value.mem.base) - elif self.is_imm(): - if operand.value.imm == self.imm: - return (True, self.imm) - else: - return (False, self.imm) - - if not self.generic: - if operand_reg != self.reg: - return (False, operand_reg) - - return (True, operand_reg) - + arch = arch_singleton.arch + + # A generic register operand may match an immediate (reg -> imm subst), + # but never a memory operand (a load address is not an immediate). + if self.abstract and self.is_reg() and operand.type == arch.op_imm: + return (True, (self.reg, operand.value.imm)) + + if self.is_mem(): + if operand.type != arch.op_mem: + return (False, None) + base = decode.reg_name(operand.value.mem.base) + if self.abstract: + if not self._alias_ok(arch, base): + return (False, None) + return (True, (self.reg, base)) + return (base == self.reg, None) + + if self.is_imm(): + if operand.type != arch.op_imm: + return (False, None) + return (operand.value.imm == self.imm, None) + + # register operand + if operand.type != arch.op_reg: + return (False, None) + reg = decode.reg_name(operand.value.reg) + if self.abstract: + if not self._alias_ok(arch, reg): + return (False, None) + return (True, (self.reg, reg)) + # Concrete registers must match exactly: writing a sub-register (ah/eax) + # is not the same as writing the full register (rax). + return (reg == self.reg, None) + + @staticmethod + def _alias_ok(arch, reg) -> bool: + ''' Whether a concrete register may fill an abstract operand. By default + only full (canonical-width) registers qualify; with register aliases + enabled, sub-registers (al, ax, eax) qualify too and are normalized + to their full register for assignment and side effects. ''' + return arch_singleton.allow_reg_aliases or arch.is_valid_abstract_reg(reg) diff --git a/rop3/parser.py b/rop3/parser.py index c90fefb..e433596 100644 --- a/rop3/parser.py +++ b/rop3/parser.py @@ -17,14 +17,8 @@ YAML = 0 -import rop3.debug as debug import rop3.parsers.yaml_parser as yaml_parser -class CompositeOperation: - def __init__(self, name, steps): - self.name = name - self.steps = steps - class Parser: def __init__(self, type_=YAML): if type_ == YAML: @@ -41,3 +35,8 @@ def get_ops(self): class ParserException(Exception): pass +class OperationNotAvailable(ParserException): + ''' Raised when an operation is explicitly marked unavailable for the + current architecture (`: {available: false}` in its YAML). ''' + pass + diff --git a/rop3/parsers/yaml_parser.py b/rop3/parsers/yaml_parser.py index 9c3f989..a0dc744 100644 --- a/rop3/parsers/yaml_parser.py +++ b/rop3/parsers/yaml_parser.py @@ -16,15 +16,18 @@ ''' import os +import re import yaml import glob -import __main__ - +import capstone + import rop3.parser as parser -import rop3.operation as operation +from rop3.operation import OperationDef from rop3.arch import arch_singleton +_OP_KEY_RE = re.compile(r'^op\d+$') + class YamlParser: def __init__(self): self.folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'roplang') @@ -67,49 +70,102 @@ def _resolve_alias(self, value): aliases = { 'REG_SP': arch.sp, 'REG_BP': arch.bp, + 'REG_FLAGS': arch.flags, } return aliases.get(value, value) - def _parse_op(self, op, content): - # Composite operation logic - if ( - isinstance(content, list) - and len(content) == 1 - and isinstance(content[0], dict) - and 'compose' in content[0] - ): - steps = content[0]['compose'] - # Resolve aliases in composite steps - resolved_steps = [] - for step in steps: - resolved_step = dict(step) - for key in ('op1', 'op2'): - if key in resolved_step: - resolved_step[key] = self._resolve_alias(resolved_step[key]) - resolved_steps.append(resolved_step) - return parser.CompositeOperation(op, resolved_steps) - - - # Normal operation - ret = operation.OperationTemplate(op) - for set_ in content: - s = operation.Set() - for item in set_: - if 'mnemonic' in item: - i = operation.Instruction(item['mnemonic']) - for operand in ('op1', 'op2'): - if operand in item: - current_op = item[operand] - if isinstance(current_op, dict): - raise NotImplementedError - else: - # Resolve aliases if necessary - i.add(operation.Operand(self._resolve_alias(item[operand]))) - elif 'operation' in item: - i = item - s.add(i) - - ret.add(s) + def _resolve_roles(self, roles): + ''' Resolve a dst/src role list, mapping arch-independent register + aliases (REG_FLAGS, REG_SP, REG_BP) to concrete register names while + leaving operand slots (op1, REG10, ...) untouched. ''' + if not roles: + return [] + return [self._resolve_alias(r) for r in roles] + + def _arch_family(self) -> str: + ''' YAML architecture block key for the current architecture. ''' + cs_arch = arch_singleton.arch.arch + if cs_arch == capstone.CS_ARCH_X86: + return 'x86' + if cs_arch in (capstone.CS_ARCH_ARM, getattr(capstone, 'CS_ARCH_ARM64', object())): + return 'arm' + if cs_arch == getattr(capstone, 'CS_ARCH_RISCV', object()): + return 'riscv' + return 'x86' - return ret + def _parse_op(self, op, content): + ''' + Parse an operation definition in the multi-architecture format: + + : + operands: N + dst: [opI, ...] + src: [opJ, ...] + : + - steps: [ {mnemonic|operation, op1, op2, ...}, ... ] + ''' + defn = OperationDef( + op, + operands=content.get('operands', 0), + dst_roles=self._resolve_roles(content.get('dst')), + src_roles=self._resolve_roles(content.get('src')), + ) + + arch_block = content.get(self._arch_family()) + if isinstance(arch_block, dict): + # Availability marker instead of a realization list, e.g. + # riscv: + # available: false + # reason: RISC-V has no condition/carry flags + if arch_block.get('available', True) is False: + defn.mark_unavailable(arch_block.get('reason')) + elif arch_block: + for entry in arch_block: + steps = entry.get('steps', []) if isinstance(entry, dict) else entry + defn.add_realization(self._realization_links(steps)) + + return defn + + def _realization_links(self, steps): + ''' + Translate the YAML steps of one realization into the neutral link data + that OperationDef.add_realization consumes. Each entry of `steps` is one + chain link: + + - a nested list of `mnemonic` steps -> a single gadget whose + instructions must appear together; + - a single `mnemonic` step -> a one-instruction gadget + (with optional implicit `writes`/`reads`); + - an `operation` step -> a reference into another + operation (replacing the old `compose:` mechanism). + + Successive links are distinct gadgets in the chain. To place several + instructions in the *same* gadget, nest them in a list. + ''' + links = [] + for entry in steps: + if isinstance(entry, list): + links.append({'gadget': [self._instruction_data(s) for s in entry]}) + elif 'mnemonic' in entry: + links.append({ + 'gadget': [self._instruction_data(entry)], + 'writes': self._resolve_roles(entry.get('writes')), + 'reads': self._resolve_roles(entry.get('reads')), + }) + elif 'operation' in entry: + bindings = { + k: self._resolve_alias(v) + for k, v in entry.items() if _OP_KEY_RE.match(k) + } + links.append({'opref': entry['operation'], 'bindings': bindings}) + + return links + + def _instruction_data(self, step): + ''' One instruction as neutral data: its mnemonic and its alias-resolved + operands in positional (op1, op2, ...) order. ''' + op_keys = sorted((k for k in step if _OP_KEY_RE.match(k)), + key=lambda k: int(k[2:])) + return {'mnemonic': step['mnemonic'], + 'operands': [self._resolve_alias(step[key]) for key in op_keys]} diff --git a/rop3/ropchain.py b/rop3/ropchain.py index 7d1f601..0add55a 100644 --- a/rop3/ropchain.py +++ b/rop3/ropchain.py @@ -21,30 +21,25 @@ import rop3.debug as debug import rop3.utils as utils -import rop3.operation as operation - -import rop3.parser as parser - -from rop3.arch import arch_singleton from .gadget import Gadget, heuristic_basic_count ''' -Matches the following with OP, DST and SRC placeholders: +Matches an operation line with an arbitrary number of comma-separated operands: -lc() -> OP: lc, DST: None, SRC: None -neg(reg1) -> OP: neg, DST: reg1, SRC: None -sc(,reg1) -> OP: sc, DST: None, SRC: reg1 -mov(reg3,reg2) -> OP: mov, DST: reg3, SRC: reg2 -mov(reg3, reg2) -> OP: mov, DST: reg3, SRC: reg2 + neg(reg1) -> OP: neg, ARGS: 'reg1' + mov(reg3, reg2) -> OP: mov, ARGS: 'reg3, reg2' + gcf-ltc(r1, r2, r3) -> OP: gcf, ARGS: 'r1, r2, r3' + sub(rax, -1) -> OP: sub, ARGS: 'rax, -1' ''' REGEX_OP = re.compile( - r'^(?P[a-zA-Z0-9-]+)' + \ - r'\((?P[a-zA-Z0-9-]+)?(, ?(?P[a-zA-Z0-9-]+))?\)' + \ + r'^(?P[a-zA-Z0-9-]+)' + r'\((?P[^)]*)\)' r'(?:\s*;.*)?$' ) COMMENT = re.compile(r'^(?:\s*;.*)?$') + class RopChain: ''' Class to construct a rop chain @@ -63,77 +58,33 @@ def search_from_gadgets(self, gadgets, ropfile) -> Iterator[list[Gadget]]: return self.search(gadgets, ropchain) def search(self, gadgets, ropchain, prune_equivalent=True) -> Iterator[list[Gadget]]: - return self._get_pruned_ropchain_iterator(gadgets, ropchain, prune_equivalent) - - def _get_pruned_ropchain_iterator(self, gadgets, ropchain, prune_equivalent) -> Iterator[list[Gadget]]: - tree = Tree(ropchain) - (combinations, ops_gadgets) = tree.traverse(gadgets) - per_comb = self._build_per_comb_gadgets(ropchain, combinations, ops_gadgets, prune_equivalent) - ops_gadgets = None - return self._construct_ropchain(ropchain, per_comb, combinations) - - def expand_steps(self, steps: list[dict], dst, src) -> list[dict]: - """ - Expands ROPLang complex OPs into ROPChains - """ - dst_key = dst if dst is not None else 'reg_dst' - src_key = src if src is not None else 'reg_src' - - expanded = [] - for step in steps: - step_op1 = step.get('op1') - step_op2 = step.get('op2') - - def resolve(placeholder, _dst=dst_key, _src=src_key): - if placeholder == 'dst': return _dst - if placeholder == 'src': return _src - return placeholder - - sub_op = step['operation'] - sub_dst = resolve(step_op1) if step_op1 else None - sub_src = resolve(step_op2) if step_op2 else None - - resolved = parser.Parser().get_op(sub_op) - if isinstance(resolved, parser.CompositeOperation): - expanded.extend(self.expand_steps(resolved.steps, sub_dst, sub_src)) - else: - expanded.append({ - 'data': f'{sub_op}({sub_dst or ""},{sub_src or ""})', - 'op': sub_op, - 'dst': sub_dst, - 'src': sub_src, - }) - - return expanded - - def _parse_ropfile(self, ropfile: str) -> list[dict[str, str]]: - ret = [] - - data = utils.read_file(ropfile).splitlines() - for i, line in enumerate(data, start=1): - match = REGEX_OP.search(line) - if match: - op_name = match.group('OP') - dst = match.group('DST') - src = match.group('SRC') - resolved = parser.Parser().get_op(op_name) - - if isinstance(resolved, parser.CompositeOperation): - ret.extend(self.expand_steps(resolved.steps, dst, src)) - else: - ret.append({ - 'data': match.group(0), - 'op': op_name, - 'dst': dst, - 'src': src, - }) - - elif COMMENT.search(line): - pass - else: - debug.error(f'{ropfile}: Line {i}: {line}: Unable to parse operation') - - return ret + ''' + `ropchain` is the parsed request: a list of steps ({op, operands}). The + gadfinder classifies it into realizations -- one per compound-operation + alternative -- each a list of (primitive_step, gadgets) pairs; every + realization is resolved by Tree and assembled by DFS. + ''' + realizations = self.gadfinder.classify_ropchain(gadgets, ropchain) + found = False + for bundle in realizations: + try: + for solution in self._assemble(bundle, prune_equivalent): + found = True + yield solution + except RopChainNotFound: + continue + if not found: + raise RopChainNotFound('no suitable ropchain combination found') + + def _assemble(self, bundle, prune_equivalent) -> Iterator[list[Gadget]]: + ''' Resolve one realization's register slots and assemble it by DFS. + `bundle` is a list of (primitive_step, classified_gadgets) pairs. ''' + steps = [step for step, _ in bundle] + ops_gadgets = [gadgets for _, gadgets in bundle] + tree = Tree(steps, ops_gadgets, self.gadfinder) + combinations = tree.traverse() + per_comb = self._build_per_comb_gadgets(steps, combinations, ops_gadgets, prune_equivalent) + return self._construct_ropchain(steps, per_comb, combinations) def _build_per_comb_gadgets( self, @@ -142,29 +93,23 @@ def _build_per_comb_gadgets( ops_gadgets: list[list[Gadget]], prune_equivalent: bool, ) -> list[list[list[Gadget]]]: - """ - For each combination, produce a per-step gadget list that is already: - - filtered to gadgets matching the combination's req_dst / req_src - - sorted by heuristic_basic_count (fewest side effects first) - - pruned of subsumed gadgets (when prune_equivalent), exploiting sort order - Returns an array indexed [comb_idx][step_idx]. - - Each operation's gadget list is sorted once up front, and the - filter+prune result is memoized per (step, req_dst, req_src): different - combinations frequently request the same concrete registers for a given - step, so this avoids recomputing the same filtered list repeatedly. - """ + ''' + For each register combination, produce a per-step gadget list already + filtered to the combination's concrete slot registers, sorted by + heuristic_basic_count, and (optionally) pruned of subsumed gadgets. The + filter+prune result is memoized per (step, req_dst, req_src). + ''' sorted_gadgets = [sorted(gl, key=heuristic_basic_count) for gl in ops_gadgets] cache: dict = {} - def build_step(i, req_dst, req_src): + def build_step(i, req_op1, req_op2): key = (i, - None if req_dst is None else str(req_dst), - None if req_src is None else str(req_src)) + None if req_op1 is None else str(req_op1), + None if req_op2 is None else str(req_op2)) if key not in cache: - filtered = [ gad for gad in sorted_gadgets[i] \ - if (req_dst is None or str(gad.dst) == str(req_dst)) \ - and (req_src is None or str(gad.src) == str(req_src)) ] + filtered = [gad for gad in sorted_gadgets[i] + if (req_op1 is None or str(gad.slot_op1) == str(req_op1)) + and (req_op2 is None or str(gad.slot_op2) == str(req_op2))] cache[key] = self._prune(filtered) if prune_equivalent else filtered return cache[key] @@ -173,19 +118,19 @@ def build_step(i, req_dst, req_src): per_step = [] for i in range(len(sorted_gadgets)): op = ropchain[i] - req_dst = comb.get(op.get('dst')) - req_src = comb.get(op.get('src')) - per_step.append(build_step(i, req_dst, req_src)) + req_op1 = comb.get(op.get('op1')) + req_op2 = comb.get(op.get('op2')) + per_step.append(build_step(i, req_op1, req_op2)) result.append(per_step) return result def _prune(self, gadget_list: list[Gadget]) -> list[Gadget]: - """ + ''' Remove gadgets subsumed by an earlier gadget in the list. Assumes all - gadgets share the same (dst, src) pair and are sorted ascending by - heuristic_basic_count - """ + gadgets share the same (slot_op1, slot_op2) and are sorted ascending by + heuristic_basic_count. + ''' ret: list[Gadget] = [] for gad in gadget_list: if not any(kept.subsumes(gad) for kept in ret): @@ -198,63 +143,43 @@ def _construct_ropchain( per_comb_gadgets: list[list[list[Gadget]]], combinations: list[dict], ) -> Iterator[list[Gadget]]: - """ - DFS over per-combination gadget lists. - Each per_comb_gadgets[i] is already filtered and optionally pruned - """ + ''' + DFS over per-combination gadget lists. Side effects are tracked with the + gadgets' dst/src register *sets*: a register a step reads must not be + clobbered, a register a step writes gets a fresh value (clearing an + earlier clobber), and a store's address register (read, not written) + keeps its clobber (issue #36). + ''' found_any = False - arch = arch_singleton.arch for comb, comb_gadgets in zip(combinations, per_comb_gadgets): - # Precompute the effective src register per step for the side-effect guard. - effective_srcs: list = [] - for op in ops_ropchain: - src_key = op.get('src') - req_src = comb.get(src_key) - if req_src is not None: - effective_srcs.append(arch.normalize_reg(req_src)) - elif src_key and not (isinstance(src_key, str) and src_key.lower().startswith('reg')): - effective_srcs.append(arch.normalize_reg(src_key)) - else: - effective_srcs.append(None) - def backtrack( - index: int, - ropchain: list[Gadget], - side_effected: Counter[str], - ) -> Iterator[list[Gadget]]: + def backtrack(index: int, chain: list[Gadget], + clobbered: Counter) -> Iterator[list[Gadget]]: if index == len(ops_ropchain): - yield ropchain.copy() - return - - effective_src = effective_srcs[index] - if effective_src and side_effected.get(effective_src, 0) > 0: + yield chain.copy() return for gad in comb_gadgets[index]: - for side_reg in gad.side_regs: - side_effected[side_reg] += 1 - - # A gadget that explicitly writes its dst produces a fresh - # value there, so clear any earlier clobber on it. Skip this - # for store operations (mov [dst], src): there dst is the - # address base register, which is read (not written), so its - # clobber state must be preserved (issue #36). - norm_dst = arch.normalize_reg(gad.dst) if gad.dst else None - refresh_dst = bool(norm_dst) and gad.writes_reg(norm_dst) - saved_dst = side_effected[norm_dst] if refresh_dst else 0 - if saved_dst: - side_effected[norm_dst] = 0 - - ropchain.append(gad) - yield from backtrack(index + 1, ropchain, side_effected) - ropchain.pop() - - if saved_dst: - side_effected[norm_dst] = saved_dst - - for side_reg in gad.side_regs: - side_effected[side_reg] -= 1 + if any(clobbered.get(reg, 0) > 0 for reg in gad.src): + continue + + for reg in gad.side_regs: + clobbered[reg] += 1 + refreshed = {} + for reg in gad.dst: + if gad.writes_reg(reg): + refreshed[reg] = clobbered.get(reg, 0) + clobbered[reg] = 0 + + chain.append(gad) + yield from backtrack(index + 1, chain, clobbered) + chain.pop() + + for reg, old in refreshed.items(): + clobbered[reg] = old + for reg in gad.side_regs: + clobbered[reg] -= 1 for valid_chain in backtrack(0, [], Counter()): found_any = True @@ -263,100 +188,103 @@ def backtrack( if not found_any: raise RopChainNotFound('no suitable ropchain combination found in DFS') + def _parse_ropfile(self, ropfile: str) -> list[dict]: + ret = [] + + data = utils.read_file(ropfile).splitlines() + for i, line in enumerate(data, start=1): + match = REGEX_OP.search(line) + if match: + op_name = match.group('OP') + args = match.group('ARGS').strip() + raw = [a.strip() for a in args.split(',')] if args else [] + operands = self._strip_legacy_commas(op_name, raw) + ret.append({ + 'data': match.group(0), + 'op': op_name, + 'operands': operands, + }) + elif COMMENT.search(line): + pass + else: + debug.error(f'{ropfile}: Line {i}: {line}: Unable to parse operation') + + return ret + + @staticmethod + def _strip_legacy_commas(op_name: str, raw: list[str]) -> list[str]: + ''' + LEGACY: older ROPLang files used a comma's position to mark an operand's + role -- a comma *after* the first operand separated dst from src + (`op(dst, src)`), and a lone source could be written with a comma + *before* it (`op(, src)`) to push it into the src slot. Operands are now + purely positional (op1, op2, ...) and a lone operand is always op1, so + the empty slot such a comma produces is dropped. A comma before the + first operand is explicitly ignored -- it never shifts the operand into + op2 -- and warns. Kept only for backward compatibility. + ''' + if raw and raw[0] == '': + debug.warning(f'{op_name}: a comma before the first operand is a legacy ' + f'dst/src marker; it is ignored (operands are positional: ' + f'op1, op2, ...)') + elif '' in raw: + debug.warning(f'{op_name}: an empty operand from a legacy dst/src comma ' + f'is ignored (operands are positional: op1, op2, ...)') + return [a for a in raw if a] + class Tree: + ''' + Resolves the concrete register assignments for the generic register slots + (regN) shared across the (already expanded, 2-operand) chain steps. Works + from the per-step classified gadgets the gadfinder produced; `gadfinder` is + consulted only for the arch's abstract-register predicate. + ''' + def __init__(self, steps, ops_gadgets, gadfinder): + self.ropchain = steps + self.ops_gadgets = ops_gadgets + self.gadfinder = gadfinder - def __init__(self, ropchain): - self.ropchain = ropchain - self.op_ropchain = self._parse_ropchain() - - def traverse(self, gadgets: list[Gadget]): - """ - Gadgets are the actual rop gadgets present in the binary. - Returns (combinations, ops_gadgets) where each combination is a flat - dict mapping every abstract-register name to a normalized concrete reg. - """ - (state, ops_gadgets, op_pairs) = self._get_initial_state(gadgets) + def traverse(self): + (state, op_pairs) = self._get_initial_state() combinations = self._traverse(state, op_pairs) debug.info(f'Exploring {len(combinations)} register combinations') - return (combinations, ops_gadgets) - - def _parse_ropchain(self) -> list[operation.Operation]: - ret = [] - arch = arch_singleton.arch - - arch_aliases = { - 'REG_SP': arch.sp, - 'REG_BP': arch.bp, - } - - def resolve(val): - if val is None: - return None - if val in arch_aliases: - return arch_aliases[val] - if isinstance(val, str) and val.lower().startswith('reg'): - return None - return val - - for item in self.ropchain: - dst = item['dst'] - src = item['src'] - - ret.append(operation.Operation(item['op'], resolve(dst), resolve(src))) - - return ret - - def _get_initial_state(self, gadgets: list[Gadget]): - """ - Build the constraint state. + return combinations - state maps each abstract-reg name (str) to the list of possible - concrete registers seen across all gadgets for that slot. - """ + def _get_initial_state(self): state: dict[str, list[str]] = {} - ops_gadgets: list[list[Gadget]] = [] op_pairs: list = [] - arch = arch_singleton.arch - def is_generic(key): return key is not None and isinstance(key, str) and key.lower().startswith('reg') - for item, op in zip(self.ropchain, self.op_ropchain): - op_gadgets = op.filter_gadgets(gadgets) - if not op_gadgets: - raise RopChainNotFound(f'{item["data"]}: Unable to find gadgets for operation') - debug.info(f'{item["data"]}: {len(op_gadgets)} matching gadgets') - - ops_gadgets.append(op_gadgets) - - dst_key, src_key = item.get('dst'), item.get('src') + for item, op_gadgets in zip(self.ropchain, self.ops_gadgets): + op1_key, op2_key = item.get('op1'), item.get('op2') - if is_generic(dst_key) and is_generic(src_key): + if is_generic(op1_key) and is_generic(op2_key): pairs = frozenset( - (g.dst, g.src) + (g.slot_op1, g.slot_op2) for g in op_gadgets - if g.has_dst() and g.has_src() - and arch.is_valid_abstract_reg(g.dst) - and arch.is_valid_abstract_reg(g.src) + if g.slot_op1 and g.slot_op2 + and self.gadfinder.is_abstract_reg(g.slot_op1) + and self.gadfinder.is_abstract_reg(g.slot_op2) ) - op_pairs.append((dst_key, src_key, pairs)) - dst_vals = sorted({p[0] for p in pairs}) - src_vals = sorted({p[1] for p in pairs}) + op_pairs.append((op1_key, op2_key, pairs)) + op1_vals = sorted({p[0] for p in pairs}) + op2_vals = sorted({p[1] for p in pairs}) else: op_pairs.append(None) - dst_vals = sorted({ - g.dst for g in op_gadgets - if g.has_dst() and arch.is_valid_abstract_reg(g.dst) - }) if is_generic(dst_key) else [] + op1_vals = sorted({ + g.slot_op1 for g in op_gadgets + if g.slot_op1 and self.gadfinder.is_abstract_reg(g.slot_op1) + }) if is_generic(op1_key) else [] - src_vals = sorted({ - g.src for g in op_gadgets - if g.has_src() and arch.is_valid_abstract_reg(g.src) - }, key=str) if is_generic(src_key) else [] + op2_vals = sorted({ + g.slot_op2 for g in op_gadgets + if g.slot_op2 and self.gadfinder.is_abstract_reg(g.slot_op2) + }, key=str) if is_generic(op2_key) else [] - for key, vals in ((dst_key, dst_vals), (src_key, src_vals)): + for key, vals in ((op1_key, op1_vals), (op2_key, op2_vals)): if key is None or not is_generic(key): continue if key in state: @@ -364,44 +292,44 @@ def is_generic(key): else: state[key] = vals - return (state, ops_gadgets, op_pairs) + return (state, op_pairs) def _traverse(self, state: dict[str, list[str]], op_pairs: list) -> list[dict[str, str]]: - """ - Returns a list of dicts: { abstract_name -> concrete_reg }. - """ + ''' + Enumerate the register assignments for the abstract slots. Distinct + slots MAY share a register: an operation can legitimately alias its + operands (e.g. `sub op1, op2 ; adc op1, REGn` with op1 == op2). Validity + is enforced by _check_pairs (only real gadget pairs) and, later, by the + DFS side-effect tracking -- not by forcing every slot to differ. + ''' items = list(state.items()) results: list[dict[str, str]] = [] - def backtrack(index: int, current: dict[str, str], used: set[str]) -> None: + def backtrack(index: int, current: dict[str, str]) -> None: if index == len(items): - if self._check_pairs(current, op_pairs): - results.append(current.copy()) + results.append(current.copy()) return key, possible_values = items[index] for val in possible_values: - if val in used: - continue current[key] = val - used.add(val) - backtrack(index + 1, current, used) + if self._check_pairs(current, op_pairs): + backtrack(index + 1, current) del current[key] - used.remove(val) - backtrack(0, {}, set()) + backtrack(0, {}) return results def _check_pairs(self, combo: dict[str, str], op_pairs: list) -> bool: for entry in op_pairs: if entry is None: continue - dst_key, src_key, pairs = entry - dst_val = combo.get(dst_key) - src_val = combo.get(src_key) - if dst_val is not None and src_val is not None: - if (dst_val, src_val) not in pairs: + op1_key, op2_key, pairs = entry + op1_val = combo.get(op1_key) + op2_val = combo.get(op2_key) + if op1_val is not None and op2_val is not None: + if (op1_val, op2_val) not in pairs: return False return True diff --git a/rop3/roplang/adc.yaml b/rop3/roplang/adc.yaml deleted file mode 100644 index 1e661fb..0000000 --- a/rop3/roplang/adc.yaml +++ /dev/null @@ -1,6 +0,0 @@ -adc: - - - - mnemonic: adc - op1: dst - op2: src - diff --git a/rop3/roplang/add.yaml b/rop3/roplang/add.yaml index bf21b1d..67b4482 100644 --- a/rop3/roplang/add.yaml +++ b/rop3/roplang/add.yaml @@ -1,15 +1,49 @@ -# Addition +# Addition: op1 <- op1 + op2 add: - # add dst, src - - - - mnemonic: add - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op1, op2] - # clc - # adc dst, src - - - - mnemonic: clc - - mnemonic: adc - op1: dst - op2: src + x86: + # add op1, op2 + - steps: + - mnemonic: add + op1: op1 + op2: op2 + # clc ; adc op1, op2 (same gadget) + - steps: + - - mnemonic: clc + - mnemonic: adc + op1: op1 + op2: op2 + + arm: + - steps: + - mnemonic: add + op1: op1 + op2: op1 + op3: op2 + + riscv: + # add op1, op1, op2 + - steps: + - mnemonic: add + op1: op1 + op2: op1 + op3: op2 + # c.add op1, op2 (op1 <- op1 + op2) + - steps: + - mnemonic: c.add + op1: op1 + op2: op2 + # addi op1, op1, imm (op1 <- op1 + imm; op2 binds to the immediate) + - steps: + - mnemonic: addi + op1: op1 + op2: op1 + op3: op2 + # c.addi op1, imm + - steps: + - mnemonic: c.addi + op1: op1 + op2: op2 diff --git a/rop3/roplang/and.yaml b/rop3/roplang/and.yaml index ad9e3f6..d72b1b9 100644 --- a/rop3/roplang/and.yaml +++ b/rop3/roplang/and.yaml @@ -1,7 +1,37 @@ -# AND +# Bitwise AND: op1 <- op1 & op2 and: - # and dst, src - - - - mnemonic: and - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op1, op2] + + x86: + - steps: + - mnemonic: and + op1: op1 + op2: op2 + + arm: + - steps: + - mnemonic: and + op1: op1 + op2: op1 + op3: op2 + + riscv: + # and op1, op1, op2 + - steps: + - mnemonic: and + op1: op1 + op2: op1 + op3: op2 + # c.and op1, op2 + - steps: + - mnemonic: c.and + op1: op1 + op2: op2 + # andi op1, op1, imm (op2 binds to the immediate) + - steps: + - mnemonic: andi + op1: op1 + op2: op1 + op3: op2 diff --git a/rop3/roplang/eqc.yaml b/rop3/roplang/eqc.yaml index 606aa70..cbdb507 100644 --- a/rop3/roplang/eqc.yaml +++ b/rop3/roplang/eqc.yaml @@ -1,10 +1,25 @@ -# Equal Comparison +# Equal comparison: flag <- (op1 == op2) eqc: - # sub(dst, src) - # neg(dst) - - compose: - - operation: sub - op1: dst - op2: src - - operation: neg - op1: dst + operands: 2 + dst: [REG_FLAGS] + src: [op1, op2] + + x86: + - steps: + - operation: sub + op1: op1 + op2: op2 + - operation: neg + op1: op1 + + arm: + - steps: + - operation: sub + op1: op1 + op2: op2 + - operation: neg + op1: op1 + + riscv: + available: false + reason: RISC-V has no condition/carry flags diff --git a/rop3/roplang/gcf-eqc.yaml b/rop3/roplang/gcf-eqc.yaml new file mode 100644 index 0000000..47af402 --- /dev/null +++ b/rop3/roplang/gcf-eqc.yaml @@ -0,0 +1,69 @@ +# Get carry flag (equal comparison). 3 operands: op3 is the flag register. +gcf-eqc: + operands: 3 + dst: [op1] + src: [op2, op3] + + x86: + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + - mnemonic: neg + op1: op2 + writes: [REG_FLAGS] + - mnemonic: adc + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + - mnemonic: neg + op1: op2 + writes: [REG_FLAGS] + - mnemonic: sbb + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + - mnemonic: neg + op1: op1 + - steps: + - operation: lc + op1: op1 + - mnemonic: sub + op1: op2 + op2: op3 + - mnemonic: neg + op1: op2 + writes: [REG_FLAGS] + - mnemonic: rcl + op1: op1 + op2: 1 + reads: [REG_FLAGS] + + arm: + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + - mnemonic: neg + op1: op2 + writes: [REG_FLAGS] + - operation: lc + op1: op1 + - mnemonic: adc + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + + riscv: + available: false + reason: RISC-V has no condition/carry flags (adc/sbb/rcl do not translate) diff --git a/rop3/roplang/gcf-ltc.yaml b/rop3/roplang/gcf-ltc.yaml new file mode 100644 index 0000000..2c6354e --- /dev/null +++ b/rop3/roplang/gcf-ltc.yaml @@ -0,0 +1,59 @@ +# Get carry flag (less-than comparison). +gcf-ltc: + operands: 3 + dst: [op1] + src: [op2, op3] + + x86: + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + writes: [REG_FLAGS] + - mnemonic: adc + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + writes: [REG_FLAGS] + - mnemonic: sbb + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + - mnemonic: neg + op1: op1 + - steps: + - operation: lc + op1: op1 + - mnemonic: sub + op1: op2 + op2: op3 + writes: [REG_FLAGS] + - mnemonic: rcl + op1: op1 + op2: 1 + reads: [REG_FLAGS] + + arm: + - steps: + - operation: lc + op1: REG10 + - mnemonic: sub + op1: op2 + op2: op3 + writes: [REG_FLAGS] + - mnemonic: adc + op1: op1 + op2: REG10 + reads: [REG_FLAGS] + + riscv: + available: false + reason: RISC-V has no condition/carry flags (adc/sbb/rcl do not translate) diff --git a/rop3/roplang/gsp.yaml b/rop3/roplang/gsp.yaml index fce1e89..e655e90 100644 --- a/rop3/roplang/gsp.yaml +++ b/rop3/roplang/gsp.yaml @@ -1,8 +1,23 @@ -# Unconditional jump +# Get stack pointer: op1 <- REG_SP gsp: - # gsp dst - - compose: - - operation: mov - op1: dst - op2: REG_SP + operands: 1 + dst: [op1] + src: [] + x86: + - steps: + - operation: mov + op1: op1 + op2: REG_SP + + arm: + - steps: + - operation: mov + op1: op1 + op2: REG_SP + + riscv: + - steps: + - operation: mov + op1: op1 + op2: REG_SP diff --git a/rop3/roplang/inc.yaml b/rop3/roplang/inc.yaml index 53cf74e..c777d0c 100644 --- a/rop3/roplang/inc.yaml +++ b/rop3/roplang/inc.yaml @@ -1,13 +1,36 @@ +# Increment: op1 <- op1 + 1 inc: - - - - mnemonic: add - op1: dst - op2: 1 - - - - mnemonic: sub - op1: dst - op2: -1 - - - - mnemonic: inc - op1: dst + operands: 1 + dst: [op1] + src: [op1] + x86: + # add op1, 1 + - steps: + - mnemonic: add + op1: op1 + op2: 1 + # sub op1, -1 + - steps: + - mnemonic: sub + op1: op1 + op2: -1 + # inc op1 + - steps: + - mnemonic: inc + op1: op1 + + arm: + - steps: + - mnemonic: add + op1: op1 + op2: op1 + op3: '#1' + + riscv: + # addi op1, op1, 1 + - steps: + - mnemonic: addi + op1: op1 + op2: op1 + op3: 1 diff --git a/rop3/roplang/jmp-rel.yaml b/rop3/roplang/jmp-rel.yaml index 705834c..e027acc 100644 --- a/rop3/roplang/jmp-rel.yaml +++ b/rop3/roplang/jmp-rel.yaml @@ -1,6 +1,26 @@ -# Unconditional branching +# Unconditional relative branching jmp-rel: - # spa(src) - - compose: - - operation: spa - op1: src + operands: 1 + dst: [op1] + src: [] + + x86: + - steps: + - operation: lc + op1: op1 + - operation: spa + op1: op1 + + arm: + - steps: + - operation: lc + op1: op1 + - operation: spa + op1: op1 + + riscv: + - steps: + - operation: lc + op1: op1 + - operation: spa + op1: op1 diff --git a/rop3/roplang/jmp.yaml b/rop3/roplang/jmp.yaml index 142bcb1..d455ddf 100644 --- a/rop3/roplang/jmp.yaml +++ b/rop3/roplang/jmp.yaml @@ -1,14 +1,25 @@ -# Unconditional jump +# Stack pivot: SP <- op1. +jmp: + operands: 1 + dst: [] + src: [op1] -leave: - - - - mnemonic: leave + x86: + # reuse mov to set the frame pointer, then a `leave` gadget + - steps: + - operation: mov + op1: REG_BP + op2: op1 + - mnemonic: leave -jmp: - # jmp src - - compose: - - operation: mov - op1: REG_BP - op2: src - - operation: leave + arm: + - steps: + - mnemonic: mov + op1: REG_SP + op2: op1 + riscv: + - steps: + - operation: mov + op1: REG_SP + op2: op1 diff --git a/rop3/roplang/lc.yaml b/rop3/roplang/lc.yaml index 5f1569a..6d89721 100644 --- a/rop3/roplang/lc.yaml +++ b/rop3/roplang/lc.yaml @@ -1,6 +1,26 @@ -# Load constant +# Load constant: op1 <- [stack] lc: - # pop dst - - - - mnemonic: pop - op1: dst + operands: 1 + dst: [op1] + src: [] + + x86: + - steps: + - mnemonic: pop + op1: op1 + + arm: + - steps: + - mnemonic: ldr + op1: op1 + op2: '[sp]' + + riscv: + - steps: + - mnemonic: ld + op1: op1 + op2: '[sp]' + - steps: + - mnemonic: lw + op1: op1 + op2: '[sp]' diff --git a/rop3/roplang/ld.yaml b/rop3/roplang/ld.yaml index 0d38640..25afe1c 100644 --- a/rop3/roplang/ld.yaml +++ b/rop3/roplang/ld.yaml @@ -1,7 +1,27 @@ -# Load +# Load from memory: op1 <- [op2] ld: - # mov dst, [src] - - - - mnemonic: mov - op1: dst - op2: '[src]' + operands: 2 + dst: [op1] + src: [op2] + + x86: + - steps: + - mnemonic: mov + op1: op1 + op2: '[op2]' + + arm: + - steps: + - mnemonic: ldr + op1: op1 + op2: '[op2]' + + riscv: + - steps: + - mnemonic: ld + op1: op1 + op2: '[op2]' + - steps: + - mnemonic: lw + op1: op1 + op2: '[op2]' diff --git a/rop3/roplang/lsd.yaml b/rop3/roplang/lsd.yaml index 458342f..aa20867 100644 --- a/rop3/roplang/lsd.yaml +++ b/rop3/roplang/lsd.yaml @@ -1,13 +1,35 @@ -# Load Stack Delta +# Load stack delta (compound). REG10 is an internal scratch register. lsd: - # lc(REG1, δ) - # neg(dst) - # and(dst, REG1) - - compose: - - operation: lc - op1: REG1 - - operation: neg - op1: dst - - operation: and - op1: dst - op2: REG1 + operands: 1 + dst: [op1] + src: [op1] + + x86: + - steps: + - operation: lc + op1: REG10 + - operation: neg + op1: op1 + - operation: and + op1: op1 + op2: REG10 + + arm: + - steps: + - operation: lc + op1: REG10 + - operation: neg + op1: op1 + - operation: and + op1: op1 + op2: REG10 + + riscv: + - steps: + - operation: lc + op1: REG10 + - operation: neg + op1: op1 + - operation: and + op1: op1 + op2: REG10 diff --git a/rop3/roplang/ltc.yaml b/rop3/roplang/ltc.yaml index 42e3653..8de23cf 100644 --- a/rop3/roplang/ltc.yaml +++ b/rop3/roplang/ltc.yaml @@ -1,7 +1,21 @@ -# Less Than Comparison +# Less-than comparison: flags <- (op1 - op2) ltc: - # sub(dst, src) - - compose: - - operation: sub - op1: dst - op2: src + operands: 2 + dst: [REG_FLAGS] + src: [op1, op2] + + x86: + - steps: + - operation: sub + op1: op1 + op2: op2 + + arm: + - steps: + - operation: sub + op1: op1 + op2: op2 + + riscv: + available: false + reason: RISC-V has no condition/carry flags (adc/sbb/rcl do not translate) diff --git a/rop3/roplang/mov.yaml b/rop3/roplang/mov.yaml index fae5504..5314c06 100644 --- a/rop3/roplang/mov.yaml +++ b/rop3/roplang/mov.yaml @@ -1,61 +1,84 @@ -# Move +# Move: op1 <- op2 mov: - # mov dst, src - - - - mnemonic: mov - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op2] - # push src - # pop dst - - - - mnemonic: push - op1: src - - mnemonic: pop - op1: dst + x86: + # mov op1, op2 + - steps: + - mnemonic: mov + op1: op1 + op2: op2 + # push op2 ; pop op1 + - steps: + - - mnemonic: push + op1: op2 + - mnemonic: pop + op1: op1 + # xchg op1, op2 + - steps: + - mnemonic: xchg + op1: op1 + op2: op2 + # xor op1, op1 ; add op1, op2 + - steps: + - - mnemonic: xor + op1: op1 + op2: op1 + - mnemonic: add + op1: op1 + op2: op2 + # xor op1, op1 ; not op1 ; and op1, op2 + - steps: + - - mnemonic: xor + op1: op1 + op2: op1 + - mnemonic: not + op1: op1 + - mnemonic: and + op1: op1 + op2: op2 + # clc ; cmovae op1, op2 + - steps: + - - mnemonic: clc + - mnemonic: cmovae + op1: op1 + op2: op2 + # stc ; cmovb op1, op2 + - steps: + - - mnemonic: stc + - mnemonic: cmovb + op1: op1 + op2: op2 - # xchg dst, src - - - - mnemonic: xchg - op1: dst - op2: src - - # xor dst, dst - # add dst, src - - - - mnemonic: xor - op1: dst - op2: dst - - mnemonic: add - op1: dst - op2: src - - # xor dst, dst - # not dst - # and dst, src - - - - mnemonic: xor - op1: dst - op2: dst - - mnemonic: not - op1: dst - - mnemonic: and - op1: dst - op2: src - - # clc - # cmovae dst, src - - - - mnemonic: clc - - mnemonic: cmovae - op1: dst - op2: src - - # stc - # cmovb dst, src - - - - mnemonic: stc - - mnemonic: cmovb - op1: dst - op2: src + arm: + # AArch64 register move (capstone renders `orr xd, xzr, xn` as `mov`). + - steps: + - mnemonic: mov + op1: op1 + op2: op2 + riscv: + # mv op1, op2 == addi op1, op2, 0 (capstone renders the pseudo) + - steps: + - mnemonic: mv + op1: op1 + op2: op2 + # c.mv op1, op2 + - steps: + - mnemonic: c.mv + op1: op1 + op2: op2 + # add op1, zero, op2 + - steps: + - mnemonic: add + op1: op1 + op2: zero + op3: op2 + # or op1, op2, zero + - steps: + - mnemonic: or + op1: op1 + op2: op2 + op3: zero diff --git a/rop3/roplang/neg.yaml b/rop3/roplang/neg.yaml index 8264c7b..4c0a304 100644 --- a/rop3/roplang/neg.yaml +++ b/rop3/roplang/neg.yaml @@ -1,4 +1,23 @@ +# Negate: op1 <- -op1 neg: - - - - mnemonic: neg - op1: dst + operands: 1 + dst: [op1] + src: [op1] + + x86: + - steps: + - mnemonic: neg + op1: op1 + + arm: + - steps: + - mnemonic: neg + op1: op1 + op2: op1 + + riscv: + # neg op1, op1 == sub op1, zero, op1 (capstone renders the pseudo) + - steps: + - mnemonic: neg + op1: op1 + op2: op1 diff --git a/rop3/roplang/not.yaml b/rop3/roplang/not.yaml index 1084db8..dcfc608 100644 --- a/rop3/roplang/not.yaml +++ b/rop3/roplang/not.yaml @@ -1,12 +1,30 @@ -# NOT +# Bitwise NOT: op1 <- ~op1 not: - # not dst - - - - mnemonic: not - op1: dst + operands: 1 + dst: [op1] + src: [op1] - # xor dst, 0xffffffff - - - - mnemonic: xor - op1: dst - op2: '0xffffffff' + x86: + # not op1 + - steps: + - mnemonic: not + op1: op1 + # TODO: Make this arch-dependent + # xor op1, 0xffffffff + # - steps: + # - mnemonic: xor + # op1: op1 + # op2: '0xffffffff' + + arm: + - steps: + - mnemonic: mvn + op1: op1 + op2: op1 + + riscv: + # not op1, op1 == xori op1, op1, -1 (capstone renders the pseudo) + - steps: + - mnemonic: not + op1: op1 + op2: op1 diff --git a/rop3/roplang/or.yaml b/rop3/roplang/or.yaml index 7095b04..91b8e0c 100644 --- a/rop3/roplang/or.yaml +++ b/rop3/roplang/or.yaml @@ -1,7 +1,37 @@ -# OR +# Bitwise OR: op1 <- op1 | op2 or: - # or dst, src - - - - mnemonic: or - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op1, op2] + + x86: + - steps: + - mnemonic: or + op1: op1 + op2: op2 + + arm: + - steps: + - mnemonic: orr + op1: op1 + op2: op1 + op3: op2 + + riscv: + # or op1, op1, op2 + - steps: + - mnemonic: or + op1: op1 + op2: op1 + op3: op2 + # c.or op1, op2 + - steps: + - mnemonic: c.or + op1: op1 + op2: op2 + # ori op1, op1, imm (op2 binds to the immediate) + - steps: + - mnemonic: ori + op1: op1 + op2: op1 + op3: op2 diff --git a/rop3/roplang/sc.yaml b/rop3/roplang/sc.yaml index f2042d2..02fe9e9 100644 --- a/rop3/roplang/sc.yaml +++ b/rop3/roplang/sc.yaml @@ -1,11 +1,38 @@ +# Stack copy: place op1 on the stack. REG10 is an internal scratch register. +# +# On x86 a ROP gadget cannot address the stack directly, so the copy is a +# push/pop round-trip (two realizations: popping back into op1 or into a +# scratch REG1). AArch64 and RISC-V address the stack directly, so the copy is +# a single store and there is one realization. sc: - - - - mnemonic: push - op1: src - - mnemonic: pop - op1: src - - - - mnemonic: push - op1: src - - mnemonic: pop - op1: REG1 + operands: 1 + dst: [] + src: [op1] + + x86: + # push op1 ; pop op1 (same gadget) + - steps: + - - mnemonic: push + op1: op1 + - mnemonic: pop + op1: op1 + # push op1 ; pop REG1 (same gadget) + - steps: + - - mnemonic: push + op1: op1 + - mnemonic: pop + op1: REG10 + + arm: + # str op1, [sp] (direct stack store; no push/pop on AArch64) + - steps: + - mnemonic: str + op1: op1 + op2: '[sp]' + + riscv: + # sd op1, off(sp) (direct stack store; no push/pop on RISC-V) + - steps: + - mnemonic: sd + op1: op1 + op2: '[sp]' diff --git a/rop3/roplang/spa.yaml b/rop3/roplang/spa.yaml index eed4a84..ed441f3 100644 --- a/rop3/roplang/spa.yaml +++ b/rop3/roplang/spa.yaml @@ -1,7 +1,23 @@ -# Stack Pointer Addition +# Stack pointer addition: SP <- SP + op1 (compound: reuses add) spa: - # add(REG_SP, src) - - compose: - - operation: add - op1: REG_SP - op2: src + operands: 1 + dst: [] + src: [op1] + + x86: + - steps: + - operation: add + op1: REG_SP + op2: op1 + + arm: + - steps: + - operation: add + op1: REG_SP + op2: op1 + + riscv: + - steps: + - operation: add + op1: REG_SP + op2: op1 diff --git a/rop3/roplang/sps.yaml b/rop3/roplang/sps.yaml index b194684..1221d6d 100644 --- a/rop3/roplang/sps.yaml +++ b/rop3/roplang/sps.yaml @@ -1,7 +1,23 @@ -# Stack Pointer Subtraction +# Stack pointer subtraction: SP <- SP - op1 (compound: reuses sub) sps: - # sub(REG_SP, src) - - compose: - - operation: sub - op1: REG_SP - op2: src + operands: 1 + dst: [] + src: [op1] + + x86: + - steps: + - operation: sub + op1: REG_SP + op2: op1 + + arm: + - steps: + - operation: sub + op1: REG_SP + op2: op1 + + riscv: + - steps: + - operation: sub + op1: REG_SP + op2: op1 diff --git a/rop3/roplang/st.yaml b/rop3/roplang/st.yaml index 4827357..0fedab3 100644 --- a/rop3/roplang/st.yaml +++ b/rop3/roplang/st.yaml @@ -1,7 +1,28 @@ -# Store +# Store to memory: [op1] <- op2 st: - # mov [dst], src - - - - mnemonic: mov - op1: '[dst]' - op2: src + operands: 2 + dst: [op1] + src: [op2] + + x86: + - steps: + - mnemonic: mov + op1: '[op1]' + op2: op2 + + arm: + - steps: + - mnemonic: str + op1: op2 + op2: '[op1]' + + riscv: + # sd op2, off(op1) (store op2 into [op1]; RV64 doubleword store) + - steps: + - mnemonic: sd + op1: op2 + op2: '[op1]' + - steps: + - mnemonic: sw + op1: op2 + op2: '[op1]' diff --git a/rop3/roplang/sub.yaml b/rop3/roplang/sub.yaml index c01b9e6..6568268 100644 --- a/rop3/roplang/sub.yaml +++ b/rop3/roplang/sub.yaml @@ -1,15 +1,38 @@ -# Subtraction +# Subtraction: op1 <- op1 - op2 sub: - # sub dst, src - - - - mnemonic: sub - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op1, op2] - # clc - # sbb dst, src - - - - mnemonic: clc - - mnemonic: sbb - op1: dst - op2: src + x86: + # sub op1, op2 + - steps: + - mnemonic: sub + op1: op1 + op2: op2 + # clc ; sbb op1, op2 (same gadget) + - steps: + - - mnemonic: clc + - mnemonic: sbb + op1: op1 + op2: op2 + + arm: + - steps: + - mnemonic: sub + op1: op1 + op2: op1 + op3: op2 + + riscv: + # sub op1, op1, op2 + - steps: + - mnemonic: sub + op1: op1 + op2: op1 + op3: op2 + # c.sub op1, op2 (op1 <- op1 - op2) + - steps: + - mnemonic: c.sub + op1: op1 + op2: op2 diff --git a/rop3/roplang/xor.yaml b/rop3/roplang/xor.yaml index 2868046..c04ea9a 100644 --- a/rop3/roplang/xor.yaml +++ b/rop3/roplang/xor.yaml @@ -1,7 +1,38 @@ -# XOR +# Bitwise XOR: op1 <- op1 ^ op2 xor: - # xor dst, src - - - - mnemonic: xor - op1: dst - op2: src + operands: 2 + dst: [op1] + src: [op1, op2] + + x86: + - steps: + - mnemonic: xor + op1: op1 + op2: op2 + + arm: + - steps: + - mnemonic: eor + op1: op1 + op2: op1 + op3: op2 + + riscv: + # xor op1, op1, op2 + - steps: + - mnemonic: xor + op1: op1 + op2: op1 + op3: op2 + # c.xor op1, op2 + - steps: + - mnemonic: c.xor + op1: op1 + op2: op2 + # xori op1, op1, imm (op2 binds to the immediate; imm == -1 renders as + # `not`, which is covered by the not operation) + - steps: + - mnemonic: xori + op1: op1 + op2: op1 + op3: op2 diff --git a/rop3/search.py b/rop3/search.py new file mode 100644 index 0000000..8e0dbbc --- /dev/null +++ b/rop3/search.py @@ -0,0 +1,224 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import re + +def galileo_scan(opcodes, base_vaddr, terminations, depth, alignment, disasm, + is_valid_gadget, accept_match=None, accept_candidate=None): + ''' + The Galileo algorithm. + + Introduced by Hovav Shacham in "The Geometry of Innocent Flesh on the Bone: + Return-into-libc without Function Calls (on the x86)" (ACM CCS 2007, S 3.2, + where he names it *Galileo*), this is the backward walk that underpins + essentially every ROP gadget finder. Instead of disassembling forward from + an entry point, it anchors on the bytes that *end* a gadget -- a `ret`, or + any free-branch termination -- and disassembles backward from each such + byte, trying every possible starting offset. On a variable-length, unaligned + ISA a single `ret` is the tail of many distinct gadgets depending on where + decoding begins, so the walk enumerates them all up to a bounded length. + + `alignment` collapses the "try every offset" step to the instruction + alignment (1 on x86; 2 or 4 on RISC-V), and `terminations` carries the + arch-specific byte patterns that end a gadget. + + Parameters + ---------- + opcodes : bytes -- executable bytes to scan. + base_vaddr : int -- virtual address of ``opcodes[0]``. + terminations : iterable of {'bytes': , 'size': } + -- gadget-terminating byte patterns and lengths. + depth : int -- maximum gadget length in bytes. + alignment : int -- instruction alignment in bytes. + disasm : callable(raw, vaddr) -> iterable -- e.g. capstone ``Cs.disasm``. + is_valid_gadget : callable(decodes) -> bool -- gadget validity. + accept_match : callable(ref) -> bool, optional -- filter on the + termination's end offset (used to partition parallel chunks). + accept_candidate : callable(vaddr, raw) -> bool, optional -- drop bad-char + addresses/bytes before disassembly. + + Yields + ------ + (vaddr, raw, decodes) + ''' + for termination in terminations: + term_size = termination['size'] + # Every reference to a gadget termination (a `ret`, a free branch, ...). + for match in re.finditer(termination['bytes'], opcodes): + ref = match.end() + if accept_match is not None and not accept_match(ref): + continue + # The terminating instruction must itself be aligned. + if alignment > 1 and (base_vaddr + match.start()) % alignment != 0: + continue + # Walk backward from the termination, growing the candidate one + # length at a time up to `depth` bytes. + for length in range(term_size, depth + 1): + start = ref - length + # Do not walk past the start of the buffer + if start < 0: + continue + vaddr = base_vaddr + start + # Gadgets may only start on an aligned boundary. + if alignment > 1 and vaddr % alignment != 0: + continue + raw = opcodes[start:ref] + if accept_candidate is not None and not accept_candidate(vaddr, raw): + continue + decodes = list(disasm(raw, vaddr)) + if is_valid_gadget(decodes): + yield vaddr, raw, decodes + + +def _linear_disasm(opcodes, base_vaddr, alignment, disasm): + ''' + Linear sweep: disassemble `opcodes` as the intended instruction stream, in + program order. Capstone stops at the first byte it cannot decode; when that + happens the sweep resynchronizes by skipping one aligned unit past the + offending byte and resumes. Returns the list of decoded instructions. + ''' + insns = [] + n = len(opcodes) + step = max(1, alignment) + off = 0 + while off < n: + produced = 0 + for insn in disasm(opcodes[off:], base_vaddr + off): + insns.append(insn) + produced += insn.size + # Resume right after the decoded run; if nothing decoded (bad byte at + # `off`), skip one aligned unit to move past it. + off += produced if produced else step + if alignment > 1 and off % alignment: + off += alignment - (off % alignment) + return insns + + +def aligned_scan(opcodes, base_vaddr, depth, alignment, disasm, + is_valid_gadget, accept_candidate=None): + ''' + Aligned (intended-instruction) gadget search. + + Where Galileo disassembles backward from *every* offset to surface + unintended gadgets hiding inside longer instructions, this scan only yields + gadgets made of the program's own intended instructions. It disassembles + each section once as a linear instruction stream, then, for every + instruction that is itself a valid termination, walks backward over the + preceding *whole* instructions -- never splitting one -- emitting each + contiguous run up to `depth` bytes. + + On a fixed-width, aligned ISA (AArch64) this finds the same gadgets as + Galileo but far faster (one disassembly pass, not one per candidate); on a + variable-length ISA it returns strictly the aligned/intended subset. + + Parameters mirror `galileo_scan`, minus the byte-pattern `terminations` + (termination points are found by disassembly here, not by a byte regex). + + Yields + ------ + (vaddr, raw, decodes) + ''' + insns = _linear_disasm(opcodes, base_vaddr, alignment, disasm) + + for i, terminator in enumerate(insns): + # A termination is any instruction that is a valid gadget on its own. + if not is_valid_gadget([terminator]): + continue + term_end = terminator.address + terminator.size + + # Walk backward over the contiguous run of intended instructions. + j = i + while j >= 0: + # Stop at a discontinuity (a resync gap): a gadget's bytes must be + # a single contiguous run. + if j < i and insns[j].address + insns[j].size != insns[j + 1].address: + break + if term_end - insns[j].address > depth: + break + + vaddr = insns[j].address + raw = opcodes[vaddr - base_vaddr:term_end - base_vaddr] + if accept_candidate is None or accept_candidate(vaddr, raw): + candidate = insns[j:i + 1] + if is_valid_gadget(candidate): + yield vaddr, raw, candidate + j -= 1 + + +# -------------------------------------------------------------------------- +# Framed aligned: aligned sweep restricted to gadgets that set up a return frame +# -------------------------------------------------------------------------- + +def framed_aligned_scan(opcodes, base_vaddr, depth, alignment, disasm, + is_valid_gadget, is_frame_load, is_return, + accept_candidate=None): + ''' + Framed aligned gadget search. + + A frame-establishing return (e.g. RISC-V `ret`, which jumps to whatever is + in `ra`) only yields a useful gadget if the run first reloads the return + target from the (attacker-controlled) stack. This specialization of the + aligned sweep keeps exactly those: it anchors on each return terminator, + walks backward over the intended instructions up to `depth`, and emits a + gadget only once the run contains a frame load. A single boolean carried + across the backward walk records whether such a load has been seen -- once + true it stays true for every longer gadget, so the check is O(1) per + candidate rather than a re-scan. + + Non-return terminators (indirect JOP branches, when enabled) carry no such + requirement and are emitted as usual. + + Parameters mirror `aligned_scan`, plus: + + is_frame_load : callable(insn) -> bool -- is `insn` the frame load that + restores the return target from the stack (RISC-V `ld ra, off(sp)`). + is_return : callable(insn) -> bool -- is `insn` a return (so the gadget + must establish its frame); false for indirect JOP terminators. + + Yields + ------ + (vaddr, raw, decodes) + ''' + insns = _linear_disasm(opcodes, base_vaddr, alignment, disasm) + + for i, terminator in enumerate(insns): + if not is_valid_gadget([terminator]): + continue + requires_frame = is_return(terminator) + term_end = terminator.address + terminator.size + + frame_loaded = False + j = i + while j >= 0: + if j < i and insns[j].address + insns[j].size != insns[j + 1].address: + break + if term_end - insns[j].address > depth: + break + + # Prepending insns[j]; once we cover the frame load the whole + # (and every longer) run establishes its return frame. + if is_frame_load(insns[j]): + frame_loaded = True + + if frame_loaded or not requires_frame: + vaddr = insns[j].address + raw = opcodes[vaddr - base_vaddr:term_end - base_vaddr] + if accept_candidate is None or accept_candidate(vaddr, raw): + candidate = insns[j:i + 1] + if is_valid_gadget(candidate): + yield vaddr, raw, candidate + j -= 1 diff --git a/rop3/utils.py b/rop3/utils.py index b9aff5b..14d29c8 100644 --- a/rop3/utils.py +++ b/rop3/utils.py @@ -57,14 +57,19 @@ def show_version(): print() print('Version: {0} v{1}'.format(TOOL_NAME, VERSION)) -def print_gadget(gadget): - print(gadget) +def _gadget_tuple_line(gadget) -> str: + ''' "[file @ addr]: " for --tuple output. ''' + return (f"[{os.path.basename(gadget.filename)} @ {hex(gadget.vaddr)}]: " + f"{gadget.tuple_repr()}") -def print_ropchain(ropchain, idx=None): +def print_gadget(gadget, fmt='text'): + print(_gadget_tuple_line(gadget) if fmt == 'tuple' else gadget) + +def print_ropchain(ropchain, idx=None, fmt='text'): if idx is not None: print('#' * 40 + f' Ropchain {idx} ' + '#' * 40) for gad in ropchain: - print(gad) + print_gadget(gad, fmt) if idx is not None: print() @@ -87,6 +92,9 @@ def output_gadgets(gadgets, fmt='text'): writer.writeheader() for gadget in gadgets: writer.writerow(_csv_record(gadget)) + elif fmt == 'tuple': + for gadget in gadgets: + print(_gadget_tuple_line(gadget)) else: for gadget in gadgets: print(gadget) @@ -95,10 +103,10 @@ def output_ropchains(chains, fmt='text', exhaustive=False): ''' Emit ROP chains (each a list of gadgets) in the requested format. For plain text without --exhaustive only the first chain is consumed, preserving the laziness of the search generator. ''' - if fmt == 'text' and not exhaustive: + if fmt in ('text', 'tuple') and not exhaustive: first = next(iter(chains), None) if first is not None: - print_ropchain(first) + print_ropchain(first, fmt=fmt) return chains = list(chains) @@ -113,30 +121,57 @@ def output_ropchains(chains, fmt='text', exhaustive=False): record = _csv_record(gadget) record['chain'] = idx writer.writerow(record) - else: + else: # 'text' or 'tuple' for idx, chain in enumerate(chains, 1): - print_ropchain(chain, idx) + print_ropchain(chain, idx, fmt=fmt) + +def binary_info_lines(info): + ''' Render a Binary.describe() dict as a list of human-readable lines for + verbose reporting. ''' + fmt = info.get('format') or 'unknown' + head = f"{os.path.basename(info['filename'])}: {fmt}, {info['arch']}, {info['bits']}-bit" + if info.get('endianness'): + head += f", {info['endianness']}-endian" + lines = [head] + + detail = f"instruction alignment: {info['alignment']} byte(s)" + if info.get('algorithm'): + detail += f", {info['algorithm']} scan" + if 'entry' in info: + detail = (f"entry: {hex(info['entry'])}, " + f"image base: {hex(info['image_base'])}, " + detail) + lines.append(detail) + + sections = info['sections'] + total = sum(s['size'] for s in sections) + lines.append(f"{len(sections)} executable section(s), {total} bytes total") + for s in sections: + name = s['name'] or 'section' + lines.append(f" {name} @ {hex(s['vaddr'])} ({s['size']} bytes)") + return lines def warning_text(text): return f'{WARNING_COLOR}{text}{END_COLOR}' -def pretty_addr(addr, mode=capstone.CS_MODE_64): - if mode == capstone.CS_MODE_32: +def pretty_addr(addr, size=8): + ''' `size` is the pointer width in bytes (4 for 32-bit, 8 for 64-bit). ''' + if size == 4: padding = 8 - elif mode == capstone.CS_MODE_64: + elif size == 8: padding = 16 else: - raise ValueError(f'unsupported mode: {mode}') + raise ValueError(f'unsupported address size: {size}') return f'{int(addr):#0{padding}x}' -def pack_addr(addr, mode=capstone.CS_MODE_64): - if mode == capstone.CS_MODE_32: +def pack_addr(addr, size=8): + ''' `size` is the pointer width in bytes (4 for 32-bit, 8 for 64-bit). ''' + if size == 4: formater = ' bytes: + text_addr: int, e_type: int = ET_DYN, symbols=None, + e_flags: int = 0) -> bytes: ''' Produce a tiny but valid ELF that pyelftools can parse: an ELF header, a `.text` PROGBITS section flagged executable at `text_addr`, and a @@ -153,7 +182,7 @@ def add_name(s): text_addr, # entry 0, # phoff shoff, # shoff - 0, # flags + e_flags, # flags ehsize, 0, 0, # ehsize, phentsize, phnum shentsize, n_sections, 2, # shentsize, shnum, shstrndx (.shstrtab) ) @@ -174,3 +203,160 @@ def add_name(s): payload += sym_entries + strtab return header + payload + b''.join(section_headers) + + +# --- Minimal in-memory Mach-O (thin + fat) builder ------------------------ + +MH_MAGIC_64 = 0xFEEDFACF +FAT_MAGIC = 0xCAFEBABE +LC_SEGMENT_64 = 0x19 +LC_SYMTAB = 0x2 +MH_EXECUTE = 2 +VM_PROT_READ = 0x1 +VM_PROT_EXECUTE = 0x4 +S_ATTR_SOME_INSTRUCTIONS = 0x400 +N_SECT = 0xe +N_EXT = 0x1 +CPU_TYPE_X86_64 = 0x01000007 +CPU_TYPE_ARM64 = 0x0100000C + + +def build_minimal_macho(cputype: int, text_bytes: bytes, + text_addr: int = 0x100000000, cpusubtype: int = 0, + symbols=None) -> bytes: + ''' + Produce a tiny thin (non-fat) 64-bit Mach-O that macholib parses: a + mach_header_64 and one LC_SEGMENT_64 (__TEXT) carrying a single executable + __text section. With `symbols` (a list of (name, value)), it also emits an + LC_SYMTAB so symbol parsing can be exercised. Enough to exercise + architecture detection and executable/symbol extraction without a + committed binary. + ''' + symbols = symbols or [] + seg_fmt = ' bytes: + ''' + Wrap several thin Mach-O slices into a fat (universal) binary that + macholib parses, so fat-only behaviour (default-slice pick, --arch + selection, absent-arch errors) can be tested without a committed macOS + binary. `slices` is a list of dicts forwarded to build_minimal_macho + (e.g. {'cputype': CPU_TYPE_X86_64, 'text_bytes': b'\\xc3'}); their order is + the file order the default-slice logic sees. The fat header is big-endian. + ''' + fat_arch_fmt = '>iiIII' # cputype, cpusubtype, offset, size, align + header_size = 8 + struct.calcsize(fat_arch_fmt) * len(slices) + align_log2 = align.bit_length() - 1 + + def roundup(x): + return (x + align - 1) & ~(align - 1) + + arches, payload = [], b'' + cursor = roundup(header_size) + for spec in slices: + thin = build_minimal_macho(**spec) + offset = cursor + payload += b'\x00' * (offset - (header_size + len(payload))) # pad to offset + payload += thin + arches.append((spec['cputype'], spec.get('cpusubtype', 0), + offset, len(thin), align_log2)) + cursor = roundup(offset + len(thin)) + + out = struct.pack('>II', FAT_MAGIC, len(slices)) + for cputype, cpusubtype, offset, size, al in arches: + out += struct.pack(fat_arch_fmt, cputype, cpusubtype, offset, size, al) + return out + payload + + +# --- Minimal in-memory PE (PE32+) builder --------------------------------- + +IMAGE_FILE_MACHINE_AMD64 = 0x8664 +IMAGE_FILE_MACHINE_ARM64 = 0xAA64 +IMAGE_SCN_CNT_CODE = 0x20 +IMAGE_SCN_MEM_EXECUTE = 0x20000000 +IMAGE_SCN_MEM_READ = 0x40000000 + + +def build_minimal_pe(machine: int, text_bytes: bytes, text_rva: int = 0x1000, + image_base: int = 0x140000000) -> bytes: + ''' + Produce a tiny PE32+ (one executable .text section) that pefile parses: a + DOS stub, the PE signature, a COFF header with `machine`, a PE32+ optional + header with 16 (empty) data directories, and one section header. Enough for + architecture detection and executable-section extraction. + ''' + file_align = 0x200 + sect_align = 0x1000 + n_dirs = 16 + + opt_head = struct.pack('. +''' + +import capstone +import pytest + +import rop3.gadfinder as gadfinder +from rop3 import Rop3 +from rop3.archs.aarch64_arch import AArch64_Architecture +from rop3.binaries.elf import ELF + +from conftest import build_minimal_elf, ET_DYN, make_operation + +EM_AARCH64 = 183 + +ADD = b'\x20\x00\x02\x8b' # add x0, x1, x2 +RET = b'\xc0\x03\x5f\xd6' # ret (0xd65f03c0) +BR_X0 = b'\x00\x00\x1f\xd6' # br x0 (indirect jump, JOP) +# Return-address restores from the stack (frame the gadget for framed search). +LDP_FRAME = bytes.fromhex('fd7bc1a8') # ldp x29, x30, [sp], #16 +LDR_LR = bytes.fromhex('fe0740f9') # ldr x30, [sp, #8] + +pytestmark = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_ARM64'), + reason='capstone build without ARM64 support') + + +def _elf(tmp_path, text): + path = tmp_path / 'a.elf' + path.write_bytes(build_minimal_elf(64, EM_AARCH64, text, 0x1000, ET_DYN)) + return str(path) + + +def test_elf_detects_aarch64_and_selects_aligned(): + arch = ELF(build_minimal_elf(64, EM_AARCH64, RET, 0x1000, ET_DYN), None).get_arch() + assert isinstance(arch, AArch64_Architecture) + assert arch.arch == capstone.CS_ARCH_ARM64 + assert (arch.address_size, arch.alignment) == (8, 4) + assert arch.scan_name == 'aligned' + assert not arch.parallelizable + + +def test_aarch64_retf_option_is_silently_ignored(): + ''' retf / ret-imm are x86-only; a non-x86 arch accepts the keyword options + (passed through by GadFinder) and returns its ordinary ROP terminations + unchanged. ''' + arch = AArch64_Architecture() + assert arch.get_rop_terminations(include_retf=True, include_ret_imm=True) \ + == arch.get_rop_terminations() + + +def test_retf_on_aarch64_scans_normally(tmp_path): + ''' Asking for retf gadgets on a non-x86 binary does not raise: the option + is silently dropped and the ordinary ROP gadgets are returned. ''' + path = _elf(tmp_path, ADD + RET) + gadgets = Rop3(path, retf=True, framed=False).gadgets() + assert any('ret' in g.text_repr for g in gadgets) + + +def test_aarch64_finds_intended_rop_gadgets(tmp_path): + path = _elf(tmp_path, ADD + ADD + RET) + reprs = {g.text_repr for g in Rop3(path, depth=16, framed=False).gadgets()} + assert 'ret' in reprs + assert 'add x0, x1, x2 ; ret' in reprs + assert 'add x0, x1, x2 ; add x0, x1, x2 ; ret' in reprs + + +def test_aarch64_gadgets_are_4byte_aligned(tmp_path): + path = _elf(tmp_path, ADD + ADD + RET) + assert all(g.vaddr % 4 == 0 for g in Rop3(path, depth=16, framed=False).gadgets()) + + +def test_aarch64_jop(tmp_path): + path = _elf(tmp_path, ADD + BR_X0) + reprs = {g.text_repr for g in Rop3(path, depth=16, rop=False, jop=True).gadgets()} + assert 'br x0' in reprs + assert 'add x0, x1, x2 ; br x0' in reprs + + +def test_aarch64_calculate_side_effects_via_regs_access(): + # capstone implements regs_access() for ARM64, so gadget annotation (the + # path that crashed on RISC-V) works through the default arch hook. + from rop3.arch import arch_singleton + from rop3.gadget import Gadget + arch_singleton.reset() + arch_singleton.initialize(AArch64_Architecture()) + md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM) + md.detail = True + decodes = list(md.disasm(ADD + RET, 0x1000)) # add x0,x1,x2 ; ret + gadget = Gadget(filename='t', arch=capstone.CS_ARCH_ARM64, + mode=capstone.CS_MODE_ARM, vaddr=0x1000, + decodes=decodes, bytes=ADD + RET) + gadget.calculate_side_effects() # must not raise + assert 'x0' in gadget.side_regs + + +def test_aarch64_written_registers_via_regs_access(): + arch = AArch64_Architecture() + md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM) + md.detail = True + add = list(md.disasm(ADD, 0x1000))[0] + ret = list(md.disasm(RET, 0x1000))[0] + assert {add.reg_name(r) for r in arch.written_registers(add)} == {'x0'} + assert arch.written_registers(ret) == set() # ret writes no GP register + + +def test_aarch64_scan_is_serial_even_with_jobs(tmp_path): + # The parallel scanner is Galileo-only; AArch64 (aligned) must stay correct + # under --jobs by running single-threaded. + path = _elf(tmp_path, ADD + ADD + RET) + serial = {g.text_repr for g in Rop3(path, depth=16, jobs=1, framed=False).gadgets()} + jobbed = {g.text_repr for g in Rop3(path, depth=16, jobs=4, framed=False).gadgets()} + assert serial == jobbed + + +# --- Framed gadget search (default on for AArch64) ------------------------ + +def test_aarch64_is_frame_load_and_is_return_predicates(): + arch = AArch64_Architecture() + md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM) + md.detail = True + one = lambda code: list(md.disasm(code, 0x1000))[0] + assert arch.is_frame_load(one(LDP_FRAME)) # ldp x29, x30, [sp], #16 + assert arch.is_frame_load(one(LDR_LR)) # ldr x30, [sp, #8] + assert not arch.is_frame_load(one(ADD)) # add does not touch the stack + assert not arch.is_frame_load(one(RET)) # ret is not a load + assert arch.is_return(one(RET)) + assert not arch.is_return(one(ADD)) + + +def test_aarch64_framed_default_requires_lr_restore(tmp_path): + # add x0,x1,x2 ; ldp x29,x30,[sp],#16 ; ret restores lr; a lone `ret` and + # `add ; ret` do not. Framed search (the default) keeps only the former. + path = _elf(tmp_path, ADD + LDP_FRAME + RET + ADD + RET) + reprs = {g.text_repr for g in Rop3(path, depth=24).gadgets()} + assert 'ldp x29, x30, [sp], #0x10 ; ret' in reprs + assert 'add x0, x1, x2 ; ldp x29, x30, [sp], #0x10 ; ret' in reprs + assert 'ret' not in reprs # bare ret does not restore lr + assert 'add x0, x1, x2 ; ret' not in reprs # nor does add ; ret + + +def test_aarch64_no_frame_keeps_unframed_gadgets(tmp_path): + # With framing disabled (--no-frame) the plain aligned sweep also keeps + # gadgets that never restore lr. + path = _elf(tmp_path, ADD + RET) + framed = {g.text_repr for g in Rop3(path, depth=24).gadgets()} + unframed = {g.text_repr for g in Rop3(path, depth=24, framed=False).gadgets()} + assert 'ret' not in framed + assert 'ret' in unframed + assert 'add x0, x1, x2 ; ret' in unframed + + +def test_aarch64_framed_does_not_gate_jop(tmp_path): + # JOP terminators (br) carry no return frame, so framed search must still + # find them (the frame requirement applies only to `ret` gadgets). + path = _elf(tmp_path, ADD + BR_X0) + reprs = {g.text_repr for g in Rop3(path, depth=24, rop=False, jop=True).gadgets()} + assert 'br x0' in reprs + assert 'add x0, x1, x2 ; br x0' in reprs + + +# --- ROPLang operation patterns (AArch64) --------------------------------- + +def _aarch64_op_matches(op, operands, body): + ''' Build a framed gadget ` ; ldp x29, x30, [sp], #16 ; ret` (the + operation first, the lr-restore in the epilogue) and return whether the + given operation matches it via the AArch64 ROPLang patterns. ''' + import rop3.operation as operation + from rop3.arch import arch_singleton + from rop3.gadget import Gadget + arch_singleton.reset() + arch_singleton.initialize(AArch64_Architecture()) + md = capstone.Cs(capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM) + md.detail = True + code = body + LDP_FRAME + RET + gadget = Gadget(filename='t', arch=capstone.CS_ARCH_ARM64, + mode=capstone.CS_MODE_ARM, vaddr=0x1000, + decodes=list(md.disasm(code, 0x1000)), bytes=code) + return bool(make_operation(op, operands).filter_gadgets([gadget])) + + +@pytest.mark.parametrize('op,operands,body', [ + ('add', ['x0', 'x1'], bytes.fromhex('0000018b')), # add x0, x0, x1 + ('add', ['x0', '8'], bytes.fromhex('00200091')), # add x0, x0, #8 (imm) + ('sub', ['x0', 'x1'], bytes.fromhex('000001cb')), # sub x0, x0, x1 + ('and', ['x0', 'x1'], bytes.fromhex('0000018a')), # and x0, x0, x1 + ('or', ['x0', 'x1'], bytes.fromhex('000001aa')), # orr x0, x0, x1 + ('xor', ['x0', 'x1'], bytes.fromhex('000001ca')), # eor x0, x0, x1 + ('neg', ['x0'], bytes.fromhex('e00300cb')), # neg x0, x0 + ('not', ['x0'], bytes.fromhex('e00320aa')), # mvn x0, x0 + ('inc', ['x0'], bytes.fromhex('00040091')), # add x0, x0, #1 + ('mov', ['x0', 'x1'], bytes.fromhex('e00301aa')), # mov x0, x1 + ('ld', ['x0', 'x1'], bytes.fromhex('200040f9')), # ldr x0, [x1] + ('st', ['x0', 'x1'], bytes.fromhex('010000f9')), # str x1, [x0] -> [x0]<-x1 + ('lc', ['x0'], bytes.fromhex('e00340f9')), # ldr x0, [sp] + ('sc', ['x0'], bytes.fromhex('e00300f9')), # str x0, [sp] (direct stack store) +]) +def test_aarch64_roplang_patterns_match(op, operands, body): + assert _aarch64_op_matches(op, operands, body) + + +def test_aarch64_lc_and_sc_do_not_use_pop(tmp_path): + # Regression: the AArch64 `lc`/`sc` blocks must not use x86 push/pop (which + # do not exist on AArch64); they load/round-trip through the stack. + import rop3.parser as parser + from rop3.arch import arch_singleton + arch_singleton.reset() + arch_singleton.initialize(AArch64_Architecture()) + for name in ('lc', 'sc'): + real = parser.Parser().get_op(name).realizations + mnems = {ins.mnemonic + for r in real for s in r.links for ins in getattr(s, 'items', [])} + assert 'pop' not in mnems and 'push' not in mnems, (name, mnems) + + +def test_aarch64_compound_ops_are_available(): + # Compound ops resolve to realizations on AArch64 (they reuse mov/lc/etc.). + import rop3.parser as parser + from rop3.arch import arch_singleton + arch_singleton.reset() + arch_singleton.initialize(AArch64_Architecture()) + for name in ('gsp', 'lsd', 'eqc', 'ltc', 'jmp', 'jmp-rel', 'spa', 'sps'): + defn = parser.Parser().get_op(name) + assert defn.available and defn.realizations, name + + +def test_aarch64_end_to_end_find_op_mov(tmp_path): + # mov x0, x1 ; ldp x29, x30, [sp], #16 ; ret realizes mov(x0, x1). + MOV = bytes.fromhex('e00301aa') # mov x0, x1 + path = _elf(tmp_path, MOV + LDP_FRAME + RET) + gadgets = Rop3(str(path), depth=24).find_op('mov', operands=['x0', 'x1']) + assert any('mov x0, x1' in g.text_repr for g in gadgets) diff --git a/tests/test_api.py b/tests/test_api.py index d26a50f..c9aca3c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -54,7 +54,7 @@ def test_rop3_accepts_single_path_or_list(elf_path): def test_rop3_find_op(elf_path): r = Rop3(elf_path) - matched = r.find_op('lc', dst='rax') + matched = r.find_op('lc', operands=['rax']) assert [g.text_repr for g in matched] == ['pop rax ; ret'] diff --git a/tests/test_arch.py b/tests/test_arch.py index 4280c48..fc5eb17 100644 --- a/tests/test_arch.py +++ b/tests/test_arch.py @@ -86,3 +86,54 @@ def test_is_valid_abstract_reg_width(): assert not X64_Architecture().is_valid_abstract_reg('eax') assert X86_Architecture().is_valid_abstract_reg('eax') assert not X86_Architecture().is_valid_abstract_reg('rax') + + +def test_rop_terminations_exclude_ret_imm_by_default(): + arch = X64_Architecture() + assert all(t['size'] == 1 for t in arch.get_rop_terminations()) # only plain ret + assert any(t['size'] == 3 for t in arch.get_rop_terminations(include_ret_imm=True)) + + +def test_x86_retf_terminator_only_with_include_retf(): + ''' retf is recognized as a ROP terminator only when far-return gadgets are + requested (the x86-only include_retf option); plain ret always is. ''' + arch = X64_Architecture() + assert 'ret' in arch._rop_terminations() + assert 'retf' not in arch._rop_terminations() + assert 'retf' in arch._rop_terminations(include_retf=True) + + +def test_include_retf_adds_retf_termination(): + arch = X64_Architecture() + without = arch.get_rop_terminations() + with_retf = arch.get_rop_terminations(include_retf=True) + assert b'\xcb' in {t['bytes'] for t in with_retf} # retf byte present + assert b'\xcb' not in {t['bytes'] for t in without} + + +def test_is_valid_rop_gadget_retf_gating(): + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64); md.detail = True + arch = X64_Architecture() + retf = list(md.disasm(b'\x58\xcb', 0)) # pop rax ; retf + assert not arch.is_valid_rop_gadget(retf) # retf is not a plain ret + assert arch.is_valid_rop_gadget(retf, include_retf=True) + + +def test_is_valid_rop_gadget_ret_imm_gating(): + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64); md.detail = True + arch = X64_Architecture() + ret_imm = list(md.disasm(b'\x58\xc2\x08\x00', 0)) # pop rax ; ret 8 + plain = list(md.disasm(b'\x58\xc3', 0)) # pop rax ; ret + assert not arch.is_valid_rop_gadget(ret_imm) + assert arch.is_valid_rop_gadget(ret_imm, allow_ret_imm=True) + assert arch.is_valid_rop_gadget(plain) + + +def test_ret_imm_anywhere_gated(): + ''' A `ret ` returns at that point, so a gadget containing one anywhere + (even as the first instruction) is excluded unless ret-imm is allowed. ''' + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64); md.detail = True + arch = X64_Architecture() + lead_ret_imm = list(md.disasm(b'\xc2\x48\x89\xc3', 0)) # ret 0x8948 ; ret + assert not arch.is_valid_rop_gadget(lead_ret_imm) + assert arch.is_valid_rop_gadget(lead_ret_imm, allow_ret_imm=True) diff --git a/tests/test_elf.py b/tests/test_elf.py index 30ab55c..0afbd53 100644 --- a/tests/test_elf.py +++ b/tests/test_elf.py @@ -27,6 +27,16 @@ SYMS = [('funcA', 0x1000), ('funcB', 0x1100)] +def test_retf_gadgets_x86_gated_by_flag(tmp_path): + ''' `retf` gadgets are only searched when retf=True; on x86 the flag is + accepted (unlike non-x86, where it raises). ''' + from rop3 import Rop3 + path = tmp_path / 'a.elf' + path.write_bytes(build_minimal_elf(64, EM_X86_64, b'\x58\xcb', 0x1000, ET_DYN)) # pop rax ; retf + assert not any('retf' in g.text_repr for g in Rop3(str(path)).gadgets()) + assert any('retf' in g.text_repr for g in Rop3(str(path), retf=True).gadgets()) + + def test_elf_detects_x64(): data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN) assert isinstance(elfmod.ELF(data, None).get_arch(), X64_Architecture) diff --git a/tests/test_gadfinder.py b/tests/test_gadfinder.py index 7330170..289b423 100644 --- a/tests/test_gadfinder.py +++ b/tests/test_gadfinder.py @@ -134,3 +134,13 @@ def test_symbol_annotation_exact_address(x86): gadgets = _run_find(gadfinder.ROP, buf, base, symbols=True, symbols_table=[(0x10000, 'start')]) assert gadgets[0].symbol == 'start' # no +offset when offset is 0 + + +def test_ret_imm_gadgets_gated_by_flag(x86): + base = 0x12345600 + buf = bytearray(0x40) + buf[0x10:0x14] = b'\x58\xc2\x08\x00' # pop eax ; ret 8 + default = [g.text_repr for g in _run_find(gadfinder.ROP, buf, base)] + assert not any('ret 8' in t for t in default) + with_imm = [g.text_repr for g in _run_find(gadfinder.ROP | gadfinder.ALLOW_RET_IMM, buf, base)] + assert 'pop eax ; ret 8' in with_imm diff --git a/tests/test_gadget.py b/tests/test_gadget.py index a1a2753..1837c9a 100644 --- a/tests/test_gadget.py +++ b/tests/test_gadget.py @@ -103,3 +103,23 @@ def test_str_includes_symbol(x64): g = make_gadget(b'\xc3', 0x1000) g.symbol = 'func+0x10' assert '' in str(g) + + +def test_result_clobbered(x64): + ''' Gadget.result_clobbered: True when a destination the operation produces + is overwritten before the terminator; False otherwise. Mirrors the + "contradictory gadget" rejection that operation.py delegates here. ''' + ok = make_gadget(b'\x48\x01\xd8\xc3', 0x1000) # add rax, rbx ; ret + bad = make_gadget(b'\x48\x01\xd8\x48\x89\xc8\xc3', 0x1010) # add rax,rbx ; mov rax,rcx ; ret + other = make_gadget(b'\x48\x01\xd8\x48\x31\xc9\xc3', 0x1020) # add rax,rbx ; xor rcx,rcx ; ret + spa = make_gadget(b'\x48\x83\xc4\x08\xc3', 0x1030) # add rsp, 8 ; ret + + # the operation is matched at index 0 (the `add`); rax is its destination + assert bad.result_clobbered([0], {'rax'}) is True # rax overwritten before ret + assert ok.result_clobbered([0], {'rax'}) is False # nothing after the add + assert other.result_clobbered([0], {'rax'}) is False # clobbers rcx, not the dst + assert bad.result_clobbered([0], set()) is False # no destinations to guard + # a store's dst (an address reg the matched insns don't write) guards nothing + assert bad.result_clobbered([0], {'rsi'}) is False + # the terminating ret's own rsp pop is control flow, not a clobber + assert spa.result_clobbered([0], {'rsp'}) is False diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..25be211 --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,106 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import pytest + +import rop3.utils as utils +from rop3 import Rop3 +from rop3.binary import Binary + +from conftest import (build_minimal_elf, EM_386, EM_X86_64, EM_RISCV, + EF_RISCV_RVC, ET_DYN) + +_riscv = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_RISCV'), + reason='capstone build without RISC-V support') + + +def _write(tmp_path, data, name='b.elf'): + path = tmp_path / name + path.write_bytes(data) + return str(path) + + +def test_describe_x86_64(tmp_path): + path = _write(tmp_path, build_minimal_elf(64, EM_X86_64, b'\x90\xc3', 0x1000, ET_DYN)) + info = Binary(path, None).describe() + assert info['format'] == 'ELF64' + assert info['arch'] == 'x86-64' + assert info['bits'] == 64 + assert info['alignment'] == 1 + assert info['endianness'] == 'little' + assert info['sections'] == [{'name': '.text', 'vaddr': 0x1000, 'size': 2}] + + +def test_describe_x86_32(tmp_path): + path = _write(tmp_path, build_minimal_elf(32, EM_386, b'\x90\xc3', 0x8048000, ET_DYN)) + info = Binary(path, None).describe() + assert (info['format'], info['arch'], info['bits']) == ('ELF32', 'x86', 32) + assert info['alignment'] == 1 + + +@_riscv +def test_describe_riscv_noncompressed(tmp_path): + path = _write(tmp_path, build_minimal_elf(64, EM_RISCV, b'\x67\x80\x00\x00', + 0x1000, ET_DYN)) + info = Binary(path, None).describe() + assert info['arch'] == 'RISC-V RV64' + assert info['alignment'] == 4 + + +@_riscv +def test_describe_riscv_compressed_reports_alignment_2(tmp_path): + # rv64gc reports e_flags == 0x5; the RVC bit relaxes alignment to 2. + path = _write(tmp_path, build_minimal_elf(64, EM_RISCV, b'\x82\x80', 0x1000, + ET_DYN, e_flags=0x5)) + info = Binary(path, None).describe() + assert info['arch'] == 'RISC-V RV64 (compressed)' + assert info['alignment'] == 2 + + +def test_binary_info_lines_formatting(): + info = { + 'filename': '/some/where/foo.elf', 'format': 'ELF64', 'arch': 'x86-64', + 'bits': 64, 'endianness': 'little', 'alignment': 1, + 'entry': 0x1040, 'image_base': 0x0, + 'sections': [{'name': '.text', 'vaddr': 0x1000, 'size': 16}], + } + lines = utils.binary_info_lines(info) + assert lines[0] == 'foo.elf: ELF64, x86-64, 64-bit, little-endian' + assert 'entry: 0x1040' in lines[1] and 'instruction alignment: 1 byte(s)' in lines[1] + assert lines[2] == '1 executable section(s), 16 bytes total' + assert lines[3] == ' .text @ 0x1000 (16 bytes)' + + +def test_binary_info_lines_tolerates_missing_optional_fields(): + ''' A loader without get_info() (no entry/endianness) still renders. ''' + info = { + 'filename': 'x.bin', 'format': None, 'arch': 'x86', 'bits': 32, + 'alignment': 1, + 'sections': [{'name': None, 'vaddr': 0x400000, 'size': 4}], + } + lines = utils.binary_info_lines(info) + assert lines[0] == 'x.bin: unknown, x86, 32-bit' + assert lines[1] == 'instruction alignment: 1 byte(s)' + assert lines[-1] == ' section @ 0x400000 (4 bytes)' + + +def test_rop3_describe_one_per_binary(tmp_path): + p1 = _write(tmp_path, build_minimal_elf(64, EM_X86_64, b'\x90\xc3', 0x1000, ET_DYN), 'a.elf') + p2 = _write(tmp_path, build_minimal_elf(32, EM_386, b'\x90\xc3', 0x8048000, ET_DYN), 'b.elf') + infos = Rop3([p1, p2]).describe() + assert [i['arch'] for i in infos] == ['x86-64', 'x86'] diff --git a/tests/test_macho.py b/tests/test_macho.py index d95c877..170f0b9 100644 --- a/tests/test_macho.py +++ b/tests/test_macho.py @@ -15,45 +15,121 @@ along with rop3. If not, see . ''' -import os - +import capstone import pytest import rop3.binaries.macho as machomod import rop3.binary as binary +from rop3 import Rop3 from rop3.archs.x86_arch import X64_Architecture +from rop3.archs.aarch64_arch import AArch64_Architecture + +from conftest import (build_minimal_macho, build_minimal_fat_macho, + CPU_TYPE_ARM64, CPU_TYPE_X86_64) + +RET_ARM = bytes.fromhex('c0035fd6') # ret +LDP_FRAME = bytes.fromhex('fd7bc1a8') # ldp x29, x30, [sp], #16 +ADD = b'\x20\x00\x02\x8b' # add x0, x1, x2 + +_arm64 = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_ARM64'), + reason='capstone build without ARM64 support') + + +# --- In-memory thin Mach-O (no committed binary) -------------------------- + +def test_inmemory_detects_x86_64(): + macho = machomod.MachO(build_minimal_macho(CPU_TYPE_X86_64, b'\xc3\xc3'), None) + assert isinstance(macho.get_arch(), X64_Architecture) -# /bin/ls on macOS is a fat Mach-O (x86_64 + arm64e): handy real fixture. -FAT = '/bin/ls' -pytestmark = pytest.mark.skipif( - not os.path.exists(FAT) or open(FAT, 'rb').read(4) != b'\xca\xfe\xba\xbe', - reason='requires a fat Mach-O binary (macOS /bin/ls)') +@_arm64 +def test_inmemory_detects_arm64(): + macho = machomod.MachO(build_minimal_macho(CPU_TYPE_ARM64, RET_ARM * 2), None) + arch = macho.get_arch() + assert isinstance(arch, AArch64_Architecture) + assert (arch.arch, arch.address_size, arch.alignment) == (capstone.CS_ARCH_ARM64, 8, 4) -def _data(): - with open(FAT, 'rb') as f: - return f.read() + +@_arm64 +def test_inmemory_explicit_arch_arm64(): + data = build_minimal_macho(CPU_TYPE_ARM64, RET_ARM) + assert isinstance(machomod.MachO(data, None, 'arm64').get_arch(), AArch64_Architecture) + + +@_arm64 +def test_inmemory_arm64_exec_section_bytes(): + macho = machomod.MachO(build_minimal_macho(CPU_TYPE_ARM64, RET_ARM * 3), None) + secs = macho.get_exec_sections() + assert len(secs) == 1 and secs[0]['opcodes'] == RET_ARM * 3 + + +def test_inmemory_unsupported_cputype_raises(): + CPU_TYPE_POWERPC = 0x12 # recognized by macholib, unsupported here + with pytest.raises(binary.BinaryException): + machomod.MachO(build_minimal_macho(CPU_TYPE_POWERPC, b'\x00\x00\x00\x00'), None) + + +def test_inmemory_absent_explicit_arch_raises(): + # An x86_64 slice does not contain arm64. + with pytest.raises(binary.BinaryException): + machomod.MachO(build_minimal_macho(CPU_TYPE_X86_64, b'\xc3'), None, 'arm64') + + +@_arm64 +def test_inmemory_arm64_end_to_end_framed_gadget(tmp_path): + path = tmp_path / 'a.macho' + path.write_bytes(build_minimal_macho(CPU_TYPE_ARM64, ADD + LDP_FRAME + RET_ARM)) + reprs = {g.text_repr for g in Rop3(str(path), depth=24).gadgets()} + assert 'ldp x29, x30, [sp], #0x10 ; ret' in reprs # framed (restores lr) + assert 'ret' not in reprs # bare ret dropped + + +# --- Synthetic fat Mach-O (x86_64 + arm64, no committed binary) ----------- +# A real macOS universal binary (e.g. /bin/ls: x86_64 + arm64e) is not +# available on the CI hosts, so fat-only behaviour is exercised against an +# in-memory fat header wrapping two thin slices, mirroring that layout: an +# x86_64 slice first (so it is the default pick) and an arm64 slice second. + +def _fat(): + return build_minimal_fat_macho([ + {'cputype': CPU_TYPE_X86_64, 'text_bytes': b'\xc3\xc3', + 'symbols': [('_main', 0x100000f00)]}, + {'cputype': CPU_TYPE_ARM64, 'text_bytes': RET_ARM * 2}, + ]) + + +def test_fat_magic_is_universal(): + # Sanity-check the synthetic wrapper really is a fat binary (FAT_MAGIC). + assert _fat()[:4] == b'\xca\xfe\xba\xbe' def test_default_slice_is_x86_64(): - assert isinstance(machomod.MachO(_data(), None).get_arch(), X64_Architecture) + assert isinstance(machomod.MachO(_fat(), None).get_arch(), X64_Architecture) def test_explicit_arch_x86_64(): - assert isinstance(machomod.MachO(_data(), None, 'x86_64').get_arch(), X64_Architecture) + assert isinstance(machomod.MachO(_fat(), None, 'x86_64').get_arch(), X64_Architecture) + + +@_arm64 +def test_explicit_arch_arm64_selects_aarch64(): + # The arm64 slice shares the ARM64 cputype with arm64e, so --arch arm64 + # selects it now that AArch64 is supported. + assert isinstance(machomod.MachO(_fat(), None, 'arm64').get_arch(), AArch64_Architecture) def test_unsupported_arch_raises(): with pytest.raises(binary.BinaryException): - machomod.MachO(_data(), None, 'arm64') + machomod.MachO(_fat(), None, 'ppc') # not a supported arch name def test_absent_arch_raises(): with pytest.raises(binary.BinaryException): - machomod.MachO(_data(), None, 'i386') # not present in /bin/ls + machomod.MachO(_fat(), None, 'i386') # supported but not present in binary def test_get_symbols_returns_list(): - syms = machomod.MachO(_data(), None).get_symbols() + syms = machomod.MachO(_fat(), None).get_symbols() assert isinstance(syms, list) assert all(isinstance(a, int) and isinstance(n, str) for a, n in syms) + assert ('_main', 0x100000f00) in {(n, a) for a, n in syms} diff --git a/tests/test_operation.py b/tests/test_operation.py index 7bba3d5..35f63b9 100644 --- a/tests/test_operation.py +++ b/tests/test_operation.py @@ -17,7 +17,7 @@ import rop3.operation as operation -from conftest import make_gadget +from conftest import make_gadget, make_operation def test_lc_matches_pop_reg(x64): @@ -27,7 +27,7 @@ def test_lc_matches_pop_reg(x64): make_gadget(b'\x5b\xc3', 0x1010), # pop rbx ; ret make_gadget(b'\x90\xc3', 0x1020), # nop ; ret (no match) ] - matched = operation.Operation('lc').filter_gadgets(gadgets) + matched = make_operation('lc').filter_gadgets(gadgets) texts = {g.text_repr for g in matched} assert 'pop rax ; ret' in texts assert 'pop rbx ; ret' in texts @@ -39,26 +39,86 @@ def test_lc_with_dst_filter(x64): make_gadget(b'\x58\xc3', 0x1000), # pop rax ; ret make_gadget(b'\x5b\xc3', 0x1010), # pop rbx ; ret ] - matched = operation.Operation('lc', dst='rax').filter_gadgets(gadgets) + matched = make_operation('lc', ['rax']).filter_gadgets(gadgets) assert [g.text_repr for g in matched] == ['pop rax ; ret'] - assert matched[0].dst == 'rax' + assert matched[0].dst == {'rax'} def test_filter_gadgets_empty_input(x64): - assert operation.Operation('lc').filter_gadgets([]) == [] + assert make_operation('lc').filter_gadgets([]) == [] def test_filter_gadgets_does_not_mutate_input(x64): ''' filter_gadgets must annotate copies, not the shared input gadgets. ''' g = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret assert g.op is None and g.dst is None - matched = operation.Operation('lc', dst='rax').filter_gadgets([g]) + matched = make_operation('lc', ['rax']).filter_gadgets([g]) assert matched and matched[0] is not g # a copy was returned - assert matched[0].op == 'lc' and matched[0].dst == 'rax' + assert matched[0].op == 'lc' and matched[0].dst == {'rax'} # original is untouched assert g.op is None and g.dst is None and g.side_regs == set() +def test_filter_gadgets_rejects_leading_junk_on_x86(x64): + ''' x86 has no frame prologue, so the operation's instruction must be the + gadget's first: a `pop rbx` behind a `mov` is not matched. ''' + g = make_gadget(b'\x48\x89\xc7\x5b\xc3', 0x1000) # mov rdi, rax ; pop rbx ; ret + assert make_operation('lc', ['rbx']).filter_gadgets([g]) == [] + # the same pop, as the first instruction, does match + g2 = make_gadget(b'\x5b\xc3', 0x1000) # pop rbx ; ret + assert [x.text_repr for x in make_operation('lc', ['rbx']).filter_gadgets([g2])] \ + == ['pop rbx ; ret'] + + +def test_filter_gadgets_requires_consecutive_operation_body(x64): + ''' A multi-instruction pattern must match a consecutive run: an + intervening instruction (`push src ; nop ; pop dst`) is not a match, + while the adjacent form (`push src ; pop dst`) is. ''' + gapped = make_gadget(b'\x53\x90\x58\xc3', 0x1000) # push rbx ; nop ; pop rax ; ret + assert make_operation('mov', ['rax', 'rbx']).filter_gadgets([gapped]) == [] + + consecutive = make_gadget(b'\x53\x58\xc3', 0x1010) # push rbx ; pop rax ; ret + matched = make_operation('mov', ['rax', 'rbx']).filter_gadgets([consecutive]) + assert [x.text_repr for x in matched] == ['push rbx ; pop rax ; ret'] + + +def test_filter_gadgets_rejects_junk_before_first_of_multi(x64): + ''' Junk before the first instruction of a multi-instruction pattern is + rejected even though the pattern is otherwise present. ''' + g = make_gadget(b'\x90\x53\x58\xc3', 0x1000) # nop ; push rbx ; pop rax ; ret + assert make_operation('mov', ['rax', 'rbx']).filter_gadgets([g]) == [] + + +def test_filter_gadgets_clobbered_destination(x64): + ''' + Only the operation's destination matters for the "contradictory gadget" + check: + - a gadget that overwrites the destination before the ret is rejected by + default and kept with reject_clobbered=False; + - clobbering a register that is not the destination is allowed; + - a stack-pointer op (`add rsp, 8 ; ret`) writes rsp, but the terminating + ret's own rsp pop is control flow, not a clobber -- a valid spa, not + contradictory. + ''' + good = make_gadget(b'\x48\x01\xd8\xc3', 0x1000) # add rax, rbx ; ret + bad = make_gadget(b'\x48\x01\xd8\x48\x89\xc8\xc3', 0x1010) # add rax, rbx ; mov rax, rcx ; ret + op = make_operation('add', ['rax', 'rbx']) + assert [g.text_repr for g in op.filter_gadgets([good, bad])] == ['add rax, rbx ; ret'] + kept = {g.text_repr for g in op.filter_gadgets([good, bad], reject_clobbered=False)} + assert kept == {'add rax, rbx ; ret', 'add rax, rbx ; mov rax, rcx ; ret'} + + # clobbering a register other than the destination is fine + other = make_gadget(b'\x48\x01\xd8\x48\x31\xc9\xc3', 0x1020) # add rax, rbx ; xor rcx, rcx ; ret + assert [x.text_repr for x in make_operation('add', ['rax', 'rbx']).filter_gadgets([other])] \ + == ['add rax, rbx ; xor rcx, rcx ; ret'] + + # the terminator's incidental rsp write does not make a stack-pointer op + # contradictory + spa = make_gadget(b'\x48\x83\xc4\x08\xc3', 0x1030) # add rsp, 8 ; ret + assert [x.text_repr for x in make_operation('add', ['rsp', '8']).filter_gadgets([spa])] \ + == ['add rsp, 8 ; ret'] + + def test_operand_parse_imm_supports_hex_and_negative(x64): ''' Regression: immediates parsed with int(x, 0). ''' op = operation.Operand('rax') @@ -77,7 +137,7 @@ def test_ld_with_src_matches_memory_not_register(x64): make_gadget(b'\x48\x8b\x03\xc3', 0x1000), # mov rax, [rbx] ; ret make_gadget(b'\x48\x89\xd8\xc3', 0x1010), # mov rax, rbx ; ret (must NOT match) ] - matched = operation.Operation('ld', src='rbx').filter_gadgets(gadgets) + matched = make_operation('ld', [None, 'rbx']).filter_gadgets(gadgets) assert [g.text_repr for g in matched] == ['mov rax, qword ptr [rbx] ; ret'] @@ -89,37 +149,37 @@ def test_ld_does_not_match_immediate_load(x64): gadgets = [ make_gadget(b'\x48\xc7\xc0\xfe\xca\x00\x00\xc3', 0x1000), # mov rax, 0xcafe ; ret ] - assert operation.Operation('ld').filter_gadgets(gadgets) == [] + assert make_operation('ld').filter_gadgets(gadgets) == [] -def test_set_dst_preserves_memory_type(x64): +def test_set_binding_preserves_memory_type(x64): ''' Regression (#33, error 1): binding a concrete register to a `[dst]` placeholder must keep the operand a memory operand, not turn it into a reg. ''' - op = operation.Operand('[dst]') + op = operation.Operand('[op1]') assert op.is_mem() - op.set_dst('rax') + op.set_binding('op1', 'rax') assert op.is_mem() assert op.reg == 'rax' -def test_set_src_preserves_memory_type(x64): +def test_set_binding_preserves_memory_type_src(x64): ''' Regression (#33, error 1): same as above for the `[src]` placeholder. ''' - op = operation.Operand('[src]') + op = operation.Operand('[op2]') assert op.is_mem() - op.set_src('rbx') + op.set_binding('op2', 'rbx') assert op.is_mem() assert op.reg == 'rbx' -def test_set_dst_accepts_immediate(x64): +def test_set_binding_accepts_immediate(x64): ''' - Regression (#33, error 2): set_dst must accept immediates like set_src, - producing an op_imm operand rather than rejecting the value. + Regression (#33, error 2): binding an operand to an immediate produces an + op_imm operand rather than rejecting the value. ''' - op = operation.Operand('dst') - op.set_dst('0x10') + op = operation.Operand('op1') + op.set_binding('op1', '0x10') assert op.is_imm() assert op.imm == 0x10 @@ -130,7 +190,7 @@ def test_xchg_src_counted_as_side_effect(x64): must be reported as a side effect (it was wrongly excluded before). ''' gadget = make_gadget(b'\x48\x93\xc3', 0x1000) # xchg rbx, rax ; ret - matched = operation.Operation('mov', dst='rbx', src='rax').filter_gadgets([gadget]) + matched = make_operation('mov', ['rbx', 'rax']).filter_gadgets([gadget]) assert len(matched) == 1 assert 'rax' in matched[0].side_regs @@ -141,5 +201,64 @@ def test_mov_matches_clc_cmovae(x64): `cmovae`/`cmovb` (not `cmovc`), so `clc ; cmovae dst, src` is a valid mov. ''' gadget = make_gadget(b'\xf8\x48\x0f\x43\xc3\xc3', 0x1000) # clc ; cmovae rax, rbx ; ret - matched = operation.Operation('mov', dst='rax', src='rbx').filter_gadgets([gadget]) + matched = make_operation('mov', ['rax', 'rbx']).filter_gadgets([gadget]) assert [g.text_repr for g in matched] == ['clc ; cmovae rax, rbx ; ret'] + + +def test_add_reports_set_valued_dst_and_src(x64): + ''' + New model: dst/src are sets of concrete register names derived from the + operation's role metadata. `add op1, op2` has dst:[op1], src:[op1, op2], so + `add rdx, rax` yields dst={rdx}, src={rax, rdx} (rdx is read and written). + ''' + gadget = make_gadget(b'\x48\x01\xc2\xc3', 0x1000) # add rdx, rax ; ret + matched = make_operation('add', ['rdx', 'rax']).filter_gadgets([gadget]) + assert len(matched) == 1 + assert matched[0].dst == {'rdx'} + assert matched[0].src == {'rax', 'rdx'} + + +def test_same_gadget_group_requires_all_instructions(x64): + ''' + mov's `push op2 ; pop op1` realization is a single gadget group, so it must + match a gadget containing BOTH instructions, not a lone `push`. + ''' + both = make_gadget(b'\x53\x58\xc3', 0x1000) # push rbx ; pop rax ; ret + only_push = make_gadget(b'\x53\xc3', 0x1010) # push rbx ; ret + matched = make_operation('mov', ['rax', 'rbx']).filter_gadgets([both, only_push]) + assert [g.text_repr for g in matched] == ['push rbx ; pop rax ; ret'] + + +def test_register_second_operand_is_detected(x64): + ''' + Regression: `add rsp, r8` (a register second operand) must be detected, + including with an unconstrained destination. The register r8 must not be + confused with the immediate 8, and vice versa. + ''' + r8 = make_gadget(b'\x4c\x01\xc4\xc3', 0x1000) # add rsp, r8 ; ret + imm = make_gadget(b'\x48\x83\xc4\x08\xc3', 0x1010) # add rsp, 8 ; ret + + assert [g.text_repr for g in make_operation('add', ['rsp', 'r8']).filter_gadgets([r8, imm])] \ + == ['add rsp, r8 ; ret'] + # unconstrained destination, concrete register source + assert [g.text_repr for g in make_operation('add', [None, 'r8']).filter_gadgets([r8, imm])] \ + == ['add rsp, r8 ; ret'] + # the immediate query must not pick up the r8 register gadget + assert [g.text_repr for g in make_operation('add', ['rsp', '8']).filter_gadgets([r8, imm])] \ + == ['add rsp, 8 ; ret'] + + +def test_reg_alias_substitution_flag(x64): + from rop3.arch import arch_singleton + pop_ax = make_gadget(b'\x66\x58\xc3', 0x1000) # pop ax ; ret (alias of rax) + # default: a sub-register does not satisfy a generic register operand + assert make_operation('lc', [None]).filter_gadgets([pop_ax]) == [] + # with aliases enabled it matches and normalizes to the full register + arch_singleton.allow_reg_aliases = True + try: + matched = make_operation('lc', [None]).filter_gadgets([pop_ax]) + assert [g.text_repr for g in matched] == ['pop ax ; ret'] + assert matched[0].slot_op1 == 'rax' + assert matched[0].dst == {'rax'} + finally: + arch_singleton.allow_reg_aliases = False diff --git a/tests/test_output.py b/tests/test_output.py index 6a090e0..06eba88 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -21,7 +21,7 @@ import rop3.utils as utils -from conftest import make_gadget +from conftest import make_gadget, make_operation def test_output_gadgets_json(x64, capsys): @@ -70,3 +70,46 @@ def gen(): raise AssertionError('second chain should not be consumed') utils.output_ropchains(gen(), 'text', exhaustive=False) assert 'pop rax ; ret' in capsys.readouterr().out + + +# --- Tuple format --------------------------------------------------------- + +def test_gadget_tuple_repr_two_operand(x64): + import rop3.operation as operation + g = make_gadget(b'\x48\x89\xc7\xc3', 0x1000) # mov rdi, rax ; ret + matched = make_operation('mov', ['rdi', 'rax']).filter_gadgets([g]) + assert matched[0].tuple_repr() == '⟨mov, rdi, rax, {rdi}, {rax}⟩' + + +def test_gadget_tuple_repr_one_operand_omits_op2(x64): + import rop3.operation as operation + g = make_gadget(b'\x48\xf7\xd8\xc3', 0x1000) # neg rax ; ret + matched = make_operation('neg', ['rax']).filter_gadgets([g]) + # -- exactly one operand before the sets. + t = matched[0].tuple_repr() + assert t.startswith('⟨neg, rax, {') and t.endswith('⟩') + assert t.count('{') == 2 # only the two reg sets + + +def test_gadget_tuple_repr_excludes_stack_pointer(x64): + # pop rax ; ret writes rax (and rsp, which is excluded); reads nothing but rsp. + t = make_gadget(b'\x58\xc3', 0x1000).tuple_repr() + assert t == '⟨, {rax}, {}⟩' # unmatched: empty op/operands + assert 'rsp' not in t + + +def test_gadget_tuple_repr_immediate_operand(x64): + import rop3.operation as operation + g = make_gadget(b'\x48\xc7\xc0\xff\xff\xff\xff\xc3', 0x1000) # mov rax, -1 ; ret + matched = make_operation('mov', ['rax']).filter_gadgets([g]) + # The immediate source shows as a literal, not a dropped/None operand. + assert matched[0].tuple_repr() == '⟨mov, rax, -1, {rax}, {}⟩' + + +def test_output_gadgets_tuple(x64, capsys): + import rop3.operation as operation + g = make_gadget(b'\x48\x89\xc7\xc3', 0x1000) # mov rdi, rax ; ret + matched = make_operation('mov', ['rdi', 'rax']).filter_gadgets([g]) + utils.output_gadgets(matched, 'tuple') + out = capsys.readouterr().out + assert '@ 0x1000]: ⟨mov, rdi, rax, {rdi}, {rax}⟩' in out diff --git a/tests/test_parser.py b/tests/test_parser.py index a7e6a76..908d3a7 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -20,11 +20,14 @@ import rop3.parser as parser +import rop3.operation as operation + + def test_get_ops_loads_roplang(x64): ops = parser.Parser().get_ops() names = {getattr(o, 'name', None) for o in ops} # a representative subset that must always be present - for expected in ('mov', 'lc', 'jmp', 'lsd', 'not', 'xor'): + for expected in ('mov', 'lc', 'add', 'sub', 'neg', 'eqc', 'gsp'): assert expected in names @@ -33,25 +36,85 @@ def test_get_op_unknown_raises(x64): parser.Parser().get_op('definitely_not_an_op') -def test_composite_op_is_recognized(x64): - ''' lsd is a composite (compose:) operation. ''' - resolved = parser.Parser().get_op('lsd') - assert isinstance(resolved, parser.CompositeOperation) - assert resolved.steps +def test_operation_metadata_parsed(x64): + ''' Operand arity and dst/src role lists come from the YAML header. ''' + add = parser.Parser().get_op('add') + assert isinstance(add, operation.OperationDef) + assert add.operands == 2 + assert add.dst_roles == ['op1'] + assert add.src_roles == ['op1', 'op2'] -def _jmp_mov_op1(): - ''' jmp is a composite whose first step is `mov REG_BP, src`. ''' - jmp = parser.Parser().get_op('jmp') - assert isinstance(jmp, parser.CompositeOperation) - mov = next(s for s in jmp.steps if s['operation'] == 'mov') - return mov['op1'] +def test_ltc_eqc_declare_flags_destination(x64): + ''' A comparison writes the flags register, not a general register: ltc/eqc + declare dst=[rflags], src=[op1, op2]. The arch-independent REG_FLAGS + alias resolves to the concrete flags register (rflags on x64). ''' + for name in ('ltc', 'eqc'): + defn = parser.Parser().get_op(name) + assert defn.dst_roles == ['rflags'], name + assert defn.src_roles == ['op1', 'op2'], name + + +def test_reg_flags_resolves_per_arch_x86(x86): + ''' On 32-bit x86 the flags register capstone reports is eflags. ''' + assert parser.Parser().get_op('ltc').dst_roles == ['eflags'] + + +def test_compound_op_has_operation_ref(x64): + ''' eqc is a compound operation: a realization made of operation refs + (replacing the old `compose:` mechanism). ''' + eqc = parser.Parser().get_op('eqc') + assert eqc.realizations + assert not any(real.is_single_gadget for real in eqc.realizations) + steps = eqc.realizations[0].links + assert all(isinstance(link, operation.OpRef) for link in steps) + assert [link.name for link in steps] == ['sub', 'neg'] + + +def _gsp_mov_op2(): + ''' gsp is a compound whose only step is `mov op1, REG_SP`. ''' + gsp = parser.Parser().get_op('gsp') + ref = gsp.realizations[0].links[0] + assert isinstance(ref, operation.OpRef) + assert ref.name == 'mov' + return ref.bindings['op2'] def test_reg_aliases_resolved_per_arch_x64(x64): - ''' REG_BP must resolve to rbp on x64 (not stay as the alias). ''' - assert _jmp_mov_op1() == 'rbp' + ''' REG_SP must resolve to rsp on x64 (not stay as the alias). ''' + assert _gsp_mov_op2() == 'rsp' def test_reg_aliases_resolved_per_arch_x86(x86): - assert _jmp_mov_op1() == 'ebp' + assert _gsp_mov_op2() == 'esp' + + +def test_removed_ops_are_gone(x64): + ''' `adc` and `leave` are no longer standalone operations; they are used as + raw mnemonics inside other operations instead. ''' + for name in ('adc', 'leave'): + with pytest.raises(parser.ParserException): + parser.Parser().get_op(name) + + +def test_nested_mnemonics_are_one_gadget(x64): + ''' A nested list of mnemonics forms a single gadget (one Set with several + instructions); mov's push/pop realization is such a group. ''' + mov = parser.Parser().get_op('mov') + pushpop = next( + r for r in mov.realizations + if len(r.links) == 1 and isinstance(r.links[0], operation.Set) + and [ins.mnemonic for ins in r.links[0].items] == ['push', 'pop'] + ) + assert len(pushpop.links[0].items) == 2 # both instructions, one gadget + + +def test_top_level_steps_are_separate_chain_links(x64): + ''' Successive top-level steps are distinct gadgets in the chain: jmp is a + `mov` operation link followed by a separate raw `leave` gadget. ''' + jmp = parser.Parser().get_op('jmp') + links = jmp.realizations[0].links + assert len(links) == 2 + assert isinstance(links[0], operation.OpRef) and links[0].name == 'mov' + assert isinstance(links[1], operation.Set) + assert [ins.mnemonic for ins in links[1].items] == ['leave'] diff --git a/tests/test_pe.py b/tests/test_pe.py new file mode 100644 index 0000000..44559b4 --- /dev/null +++ b/tests/test_pe.py @@ -0,0 +1,69 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import pytest + +import rop3.binary as binary +from rop3 import Rop3 +from rop3.binaries.pe import PE +from rop3.archs.x86_arch import X64_Architecture +from rop3.archs.aarch64_arch import AArch64_Architecture + +from conftest import (build_minimal_pe, IMAGE_FILE_MACHINE_AMD64, + IMAGE_FILE_MACHINE_ARM64) + +RET_ARM = bytes.fromhex('c0035fd6') # ret +LDP_FRAME = bytes.fromhex('fd7bc1a8') # ldp x29, x30, [sp], #16 +ADD = b'\x20\x00\x02\x8b' # add x0, x1, x2 + +_arm64 = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_ARM64'), + reason='capstone build without ARM64 support') + + +def test_pe_detects_amd64(): + arch = PE(build_minimal_pe(IMAGE_FILE_MACHINE_AMD64, b'\xc3\xc3'), None).get_arch() + assert isinstance(arch, X64_Architecture) + + +@_arm64 +def test_pe_detects_arm64(): + arch = PE(build_minimal_pe(IMAGE_FILE_MACHINE_ARM64, RET_ARM * 2), None).get_arch() + assert isinstance(arch, AArch64_Architecture) + assert (arch.arch, arch.address_size, arch.alignment) == (capstone.CS_ARCH_ARM64, 8, 4) + + +@_arm64 +def test_pe_arm64_exec_section_bytes(): + pe = PE(build_minimal_pe(IMAGE_FILE_MACHINE_ARM64, RET_ARM * 3), None) + secs = pe.get_exec_sections() + assert len(secs) == 1 and secs[0]['opcodes'] == RET_ARM * 3 + + +def test_pe_unsupported_machine_raises(): + IMAGE_FILE_MACHINE_ARM = 0x01c0 # 32-bit ARM (not supported) + with pytest.raises(binary.BinaryException): + PE(build_minimal_pe(IMAGE_FILE_MACHINE_ARM, b'\x00\x00'), None) + + +@_arm64 +def test_pe_arm64_end_to_end_framed_gadget(tmp_path): + path = tmp_path / 'a.exe' + path.write_bytes(build_minimal_pe(IMAGE_FILE_MACHINE_ARM64, ADD + LDP_FRAME + RET_ARM)) + reprs = {g.text_repr for g in Rop3(str(path), depth=24).gadgets()} + assert 'ldp x29, x30, [sp], #0x10 ; ret' in reprs # framed (restores lr) + assert 'ret' not in reprs # bare ret dropped diff --git a/tests/test_riscv.py b/tests/test_riscv.py new file mode 100644 index 0000000..5ddcd66 --- /dev/null +++ b/tests/test_riscv.py @@ -0,0 +1,380 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import pytest + +import rop3.gadfinder as gadfinder +from rop3.archs.riscv_arch import RISCV_Architecture +from rop3.binaries.elf import ELF + +from conftest import build_minimal_elf, EM_RISCV, EF_RISCV_RVC, ET_DYN, make_operation + +# jalr x0, 0(ra) == ret (0x00008067, little-endian) +RET = b'\x67\x80\x00\x00' +# c.jr ra == ret (compressed, 0x8082) +C_RET = b'\x82\x80' +# addi a0, a1, 0 == mv a0, a1 (0x00058513) +MV_A0_A1 = b'\x13\x85\x05\x00' +# ld ra, 8(sp) -- restores the return address from the stack (0x00813083) +LD_RA_SP = b'\x83\x30\x81\x00' +# c.ldsp ra, 8(sp) -- compressed ra restore (0x60a2) +C_LDSP_RA = b'\xa2\x60' + +pytestmark = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_RISCV'), + reason='capstone build without RISC-V support') + + +# --- ELF detection & alignment ------------------------------------------- + +def test_elf_detects_riscv64(): + arch = ELF(build_minimal_elf(64, EM_RISCV, RET, 0x1000, ET_DYN), None).get_arch() + assert isinstance(arch, RISCV_Architecture) + assert arch.arch == capstone.CS_ARCH_RISCV + assert arch.address_size == 8 + assert arch.alignment == 4 # no C extension advertised + + +def test_elf_rvc_flag_enables_compressed_and_2byte_alignment(): + arch = ELF(build_minimal_elf(64, EM_RISCV, C_RET, 0x1000, ET_DYN, + e_flags=EF_RISCV_RVC), None).get_arch() + assert arch.alignment == 2 + assert arch.mode & capstone.CS_MODE_RISCVC + + +def test_elf_rvc_detected_within_rv64gc_flags(): + ''' rv64gc binaries report e_flags == 0x5 (RVC | float-abi-double); only + the RVC bit (0x1) governs instruction alignment. ''' + arch = ELF(build_minimal_elf(64, EM_RISCV, C_RET, 0x1000, ET_DYN, + e_flags=0x5), None).get_arch() + assert arch.alignment == 2 + + +def test_elf_riscv32_not_supported(): + with pytest.raises(NotImplementedError): + ELF(build_minimal_elf(32, EM_RISCV, RET, 0x1000, ET_DYN), None) + + +# --- Architecture descriptors -------------------------------------------- + +def test_riscv_rop_terminations(): + assert [t['bytes'] for t in RISCV_Architecture().get_rop_terminations()] == [RET] + compressed = RISCV_Architecture(compressed=True).get_rop_terminations() + assert {t['size'] for t in compressed} == {4, 2} + + +def test_riscv_abstract_registers(): + arch = RISCV_Architecture() + assert arch.is_valid_abstract_reg('a0') + assert arch.is_valid_abstract_reg('sp') + assert not arch.is_valid_abstract_reg('zero') + assert not arch.is_valid_abstract_reg('x0') + + +# --- Validity via real disassembly --------------------------------------- + +def _disasm(code, compressed=False): + mode = capstone.CS_MODE_RISCV64 + if compressed: + mode |= capstone.CS_MODE_RISCVC + md = capstone.Cs(capstone.CS_ARCH_RISCV, mode) + md.detail = True + return list(md.disasm(code, 0x1000)) + + +def test_riscv_ret_is_valid_rop_gadget(): + decodes = _disasm(RET) + assert decodes[-1].mnemonic == 'ret' + assert RISCV_Architecture().is_valid_rop_gadget(decodes) + + +def test_riscv_compressed_ret_is_valid_rop_gadget(): + # Capstone renders the compressed return `c.jr ra` under its own mnemonic. + decodes = _disasm(C_RET, compressed=True) + assert (decodes[-1].mnemonic, decodes[-1].op_str) == ('c.jr', 'ra') + assert RISCV_Architecture(compressed=True).is_valid_rop_gadget(decodes) + + +def test_riscv_compressed_indirect_jump_is_not_a_return(): + # `c.jr t0` is an indirect jump, not a stack-driven return. + decodes = _disasm(b'\x82\x82', compressed=True) # c.jr t0 + assert decodes[-1].mnemonic == 'c.jr' + assert not RISCV_Architecture(compressed=True).is_valid_rop_gadget(decodes) + + +# --- End-to-end gadget search -------------------------------------------- + +def _elf_path(tmp_path, text, e_flags=0): + data = build_minimal_elf(64, EM_RISCV, text, 0x1000, ET_DYN, e_flags=e_flags) + path = tmp_path / 'sample.elf' + path.write_bytes(data) + return str(path) + + +def test_gadfinder_finds_ra_restoring_gadget(tmp_path): + # ld ra, 8(sp) ; ret -- a real RISC-V ROP gadget (restores ra from stack). + path = _elf_path(tmp_path, LD_RA_SP + RET) + finder = gadfinder.GadFinder(depth=8, flags=gadfinder.ROP) + reprs = {g.text_repr for g in finder.find([path])} + assert 'ld ra, 8(sp) ; ret' in reprs + assert 'ret' not in reprs # a bare ret does not restore ra + + +def test_gadfinder_drops_gadget_without_ra_restore(tmp_path): + # mv a0, a1 ; ret does not reload ra, so it is not a usable ROP gadget. + path = _elf_path(tmp_path, MV_A0_A1 + RET) + finder = gadfinder.GadFinder(depth=8, flags=gadfinder.ROP) + assert finder.find([path]) == [] + + +def test_riscv_default_depth_fits_a_framed_gadget(): + # The x86 default (5 bytes) cannot fit `ld ra, off(sp) ; ret` (8 bytes), so + # RISC-V must raise its default or the finder silently returns nothing. + assert RISCV_Architecture().default_depth >= len(LD_RA_SP + RET) + + +def test_gadfinder_default_depth_finds_riscv_gadget(tmp_path): + # Regression: with no explicit --depth, the finder must use the RISC-V + # architecture default (not the 5-byte x86 default) and still find gadgets. + path = _elf_path(tmp_path, LD_RA_SP + RET) + finder = gadfinder.GadFinder(flags=gadfinder.ROP) # depth defaults per-arch + reprs = {g.text_repr for g in finder.find([path])} + assert 'ld ra, 8(sp) ; ret' in reprs + + +def test_riscv_written_registers_from_encoding(): + # capstone raises on regs_access() for RISC-V, so writes come from the + # encoding: rd is operand 0, absent for stores/branches/register-jumps. + arch = RISCV_Architecture(compressed=True) + + def writes(code): + insn = _disasm(code, compressed=True)[0] + return {insn.reg_name(r) for r in arch.written_registers(insn)} + + assert writes(b'\x33\x85\xc5\x00') == {'a0'} # add a0, a1, a2 + assert writes(b'\x03\xb5\x05\x00') == {'a0'} # ld a0, 0(a1) + assert writes(b'\x2e\x95') == {'a0'} # c.add a0, a1 + assert writes(b'\x23\xb0\xb5\x00') == set() # sd (store) -> no reg write + assert writes(b'\x82\x80') == set() # c.jr ra + assert writes(b'\x67\x80\x00\x00') == set() # ret + + # The encoding-derived writes drive operand semantics through + # Operation.filter_gadgets. Two consequences worth pinning here: + import rop3.operation as operation + from rop3.arch import arch_singleton + from rop3.gadget import Gadget + arch_singleton.reset() + arch_singleton.initialize(RISCV_Architecture(compressed=True)) + mode = capstone.CS_MODE_RISCV64 | capstone.CS_MODE_RISCVC + md = capstone.Cs(capstone.CS_ARCH_RISCV, mode) + md.detail = True + + def gadget(code): + return Gadget(filename='t', arch=capstone.CS_ARCH_RISCV, mode=mode, + vaddr=0x1000, decodes=list(md.disasm(code, 0x1000)), bytes=code) + + # (1) An immediate operand (addi a0, a0, 8) must not leak into the src + # register set or slot_op2, and an immediate query must not match a register + # add gadget (mirrors the x86 immediate handling). + imm = gadget(LD_RA_SP + _i(0x13, 0, 10, 10, 8) + RET) # ld ra ; addi a0,a0,8 ; ret + matched = make_operation('add', ['a0', '8']).filter_gadgets([imm]) + assert matched and matched[0].src == {'a0'} and matched[0].slot_op2 is None + reg = gadget(LD_RA_SP + _r(0x33, 0, 0x00, 10, 10, 11) + RET) # ld ra ; add a0,a0,a1 ; ret + assert make_operation('add', ['a0', '8']).filter_gadgets([reg]) == [] + + # (2) A store writes no register (its result is in memory), so reusing its + # address register afterwards does not make the gadget contradictory. + sd = _s(0x23, 3, 11, 10, 0) # sd a0, 0(a1) + mv = _i(0x13, 0, 11, 12, 0) # mv a1, a2 (reuses a1) + reuse = gadget(LD_RA_SP + sd + mv + RET) + assert make_operation('st', ['a1', 'a0']).filter_gadgets([reuse]) + + +def test_riscv_calculate_side_effects_without_regs_access(): + # Regression: gadget annotation crashed with CS_ERR_ARCH because side-effect + # computation called capstone's regs_access(), unimplemented for RISC-V; it + # now derives writes from the encoding instead. + from rop3.arch import arch_singleton + from rop3.gadget import Gadget + arch_singleton.reset() + arch_singleton.initialize(RISCV_Architecture()) + code = b'\x33\x85\xc5\x00' + RET # add a0, a1, a2 ; ret + decodes = _disasm(code) + gadget = Gadget(filename='t', arch=capstone.CS_ARCH_RISCV, + mode=RISCV_Architecture().mode, vaddr=0x1000, + decodes=decodes, bytes=code) + gadget.calculate_side_effects() # must not raise + assert 'a0' in gadget.side_regs + + +def test_riscv_frame_prefix_lets_operation_follow_ra_load(): + ''' On RISC-V the ra restore frames a gadget, so an operation may sit right + after it -- but not behind other (non-frame) instructions. ''' + import rop3.operation as operation + from rop3.arch import arch_singleton + arch_singleton.reset() + arch_singleton.initialize(RISCV_Architecture(compressed=True)) + + pattern = operation.Set() # c.add op1, op2 + ins = operation.Instruction('c.add') + ins.add(operation.Operand('op1')) + ins.add(operation.Operand('op2')) + pattern.add(ins) + + C_ADD = b'\x2e\x95' # c.add a0, a1 + + # ld ra, 8(sp) ; c.add a0, a1 ; ret -- operation after the ra-load frame + assert pattern.is_equal(_disasm(LD_RA_SP + C_ADD + RET, compressed=True))[0] + # c.add a0, a1 ; ld ra, 8(sp) ; ret -- operation first (frame in epilogue) + assert pattern.is_equal(_disasm(C_ADD + LD_RA_SP + RET, compressed=True))[0] + # mv a0, a1 ; c.add a0, a1 ; ret -- non-frame junk before the operation + assert not pattern.is_equal(_disasm(MV_A0_A1 + C_ADD + RET, compressed=True))[0] + + +def _riscv_op_matches(op, operands, body): + ''' Build a framed gadget `ld ra, 8(sp) ; ; ret` and return whether + the given operation matches it via the RISC-V ROPLang patterns. ''' + import rop3.operation as operation + from rop3.arch import arch_singleton + from rop3.gadget import Gadget + arch_singleton.reset() + arch_singleton.initialize(RISCV_Architecture(compressed=True)) + mode = capstone.CS_MODE_RISCV64 | capstone.CS_MODE_RISCVC + md = capstone.Cs(capstone.CS_ARCH_RISCV, mode) + md.detail = True + code = LD_RA_SP + body + RET + gadget = Gadget(filename='t', arch=capstone.CS_ARCH_RISCV, mode=mode, + vaddr=0x1000, decodes=list(md.disasm(code, 0x1000)), bytes=code) + return bool(make_operation(op, operands).filter_gadgets([gadget])) + + +def _r(op, f3, f7, rd, rs1, rs2): + import struct + return struct.pack('> 5) & 0x7f) << 25) | (rs2 << 20) | (rs1 << 15) + | (f3 << 12) | ((imm & 0x1f) << 7) | op) + + +# rd=a0(10), rs1/rs2 = a0(10)/a1(11), sp=2, zero=0 +@pytest.mark.parametrize('op,operands,body', [ + ('add', ['a0', 'a1'], _r(0x33, 0, 0x00, 10, 10, 11)), # add a0,a0,a1 + ('add', ['a0', 'a1'], b'\x2e\x95'), # c.add a0,a1 + ('sub', ['a0', 'a1'], _r(0x33, 0, 0x20, 10, 10, 11)), # sub a0,a0,a1 + ('and', ['a0', 'a1'], _r(0x33, 7, 0x00, 10, 10, 11)), # and a0,a0,a1 + ('or', ['a0', 'a1'], _r(0x33, 6, 0x00, 10, 10, 11)), # or a0,a0,a1 + ('xor', ['a0', 'a1'], _r(0x33, 4, 0x00, 10, 10, 11)), # xor a0,a0,a1 + ('inc', ['a0'], _i(0x13, 0, 10, 10, 1)), # addi a0,a0,1 + ('neg', ['a0'], _r(0x33, 0, 0x20, 10, 0, 10)), # neg a0,a0 + ('not', ['a0'], _i(0x13, 4, 10, 10, -1)), # not a0,a0 + ('mov', ['a0', 'a1'], _i(0x13, 0, 10, 11, 0)), # mv a0,a1 + ('mov', ['a0', 'a1'], b'\x2e\x85'), # c.mv a0,a1 + ('mov', ['a0', 'a1'], _r(0x33, 0, 0x00, 10, 0, 11)), # add a0,zero,a1 + ('lc', ['a0'], _i(0x03, 3, 10, 2, 16)), # ld a0,16(sp) + ('ld', ['a0', 'a1'], _i(0x03, 3, 10, 11, 0)), # ld a0,0(a1) + ('st', ['a1', 'a0'], _s(0x23, 3, 11, 10, 0)), # sd a0,0(a1) -> [a1]<-a0 + # immediate forms: addi/andi/ori/xori reg, reg, #imm == op(reg, #imm) + ('add', ['a0', '8'], _i(0x13, 0, 10, 10, 8)), # addi a0,a0,8 + ('add', ['a0', '8'], b'\x21\x05'), # c.addi a0,8 + ('and', ['a0', '12'], _i(0x13, 7, 10, 10, 12)), # andi a0,a0,12 + ('or', ['a0', '5'], _i(0x13, 6, 10, 10, 5)), # ori a0,a0,5 + ('xor', ['a0', '5'], _i(0x13, 4, 10, 10, 5)), # xori a0,a0,5 +]) +def test_riscv_roplang_patterns_match(op, operands, body): + assert _riscv_op_matches(op, operands, body) + + +def test_riscv_jmp_is_a_stack_pivot(tmp_path): + ''' jmp is a stack pivot (SP <- op1), realized by reusing mov to write sp; + a framed `mv sp, a0` gadget realizes jmp(a0). ''' + import struct + import rop3.parser as parser + from rop3 import Rop3 + from rop3.arch import arch_singleton + arch_singleton.reset() + arch_singleton.initialize(RISCV_Architecture()) + + # Resolves to a compound reusing mov(REG_SP -> sp, op1). + jmp = parser.Parser().get_op('jmp') + assert not jmp.realizations[0].is_single_gadget + ref = jmp.realizations[0].links[0] + assert ref.name == 'mov' and ref.bindings == {'op1': 'sp', 'op2': 'op1'} + + # end-to-end: ld ra, 8(sp) ; mv sp, a0 ; ret realizes jmp(a0) + mv_sp_a0 = struct.pack(' unavailable arch + + +def test_search_tries_every_realization(x64): + ''' The search must try each realization: gadgets that satisfy only a + non-first realization of gcf-eqc (the `rcl` variant) still yield a + chain. ''' + gadgets = [ + make_gadget(b'\x58\xc3', 0x10), # pop rax ; ret + make_gadget(b'\x48\x29\xcb\xc3', 0x20), # sub rbx, rcx ; ret + make_gadget(b'\x48\xf7\xdb\xc3', 0x30), # neg rbx ; ret + make_gadget(b'\x48\xd1\xd0\xc3', 0x40), # rcl rax, 1 ; ret (only realization) + ] + step = {'op': 'gcf-eqc', 'operands': ['rax', 'rbx', 'rcx'], 'data': 'gcf-eqc(rax,rbx,rcx)'} + results = list(RopChain(GadFinder()).search(gadgets, [step], prune_equivalent=False)) + assert results + assert results[0][-1].text_repr == 'rcl rax, 1 ; ret' + + +def test_search_compound_op_gcf_eqc(x64): + ''' + End-to-end search of a compound operation. gcf-eqc(op1, op2, op3), first + realization, expands (nested) to: + + lc(REG1) -> pop REG1 (REG1 is a scratch register) + eqc(op2, op3) -> sub(op2, op3) ; neg(op2) + adc op1, REG1 -> adc op1, REG1 + + so gcf-eqc(rax, rbx, rcx) must assemble + + pop ; sub rbx, rcx ; neg rbx ; adc rax, + + with REG1 unified between the pop and the adc. The `pop rsi` decoy is + rejected because no `adc rax, rsi` exists, forcing REG1 = rdx. + ''' + gadgets = [ + make_gadget(b'\x5a\xc3', 0x10), # pop rdx ; ret + make_gadget(b'\x5e\xc3', 0x18), # pop rsi ; ret (decoy scratch) + make_gadget(b'\x48\x29\xcb\xc3', 0x20), # sub rbx, rcx ; ret + make_gadget(b'\x48\xf7\xdb\xc3', 0x30), # neg rbx ; ret + make_gadget(b'\x48\x11\xd0\xc3', 0x40), # adc rax, rdx ; ret + ] + step = {'op': 'gcf-eqc', 'operands': ['rax', 'rbx', 'rcx'], 'data': 'gcf-eqc(rax,rbx,rcx)'} + results = list(RopChain(GadFinder()).search(gadgets, [step])) + assert results + assert [g.text_repr for g in results[0]] == [ + 'pop rdx ; ret', + 'sub rbx, rcx ; ret', + 'neg rbx ; ret', + 'adc rax, rdx ; ret', + ] + + +def test_gcf_ltc_rejects_flag_clobbering_comparison(x64): + ''' + The carry flag the final adc/sbb/rcl consumes is produced by the comparison + (an inlined `sub`, which now `writes: [REG_FLAGS]`). A comparison gadget + that overwrites the flags before its `ret` (here a trailing `test`) is + contradictory -- its carry never reaches the consumer -- so with only such a + `sub` available, gcf-ltc must not assemble. + ''' + gadgets = [ + make_gadget(b'\x5a\xc3', 0x10), # pop rdx ; ret + # sub rbx, rcx ; test rdx, rdx ; ret -- `test` clobbers the flags + make_gadget(b'\x48\x29\xcb\x48\x85\xd2\xc3', 0x20), + make_gadget(b'\x48\x11\xd0\xc3', 0x40), # adc rax, rdx ; ret + ] + step = {'op': 'gcf-ltc', 'op1': None, 'op2': None, 'data': 'gcf-ltc()'} + with pytest.raises(ropchain_mod.RopChainNotFound): + list(RopChain(GadFinder()).search(gadgets, [step])) + + +def test_search_compound_op_with_generic_operands(x64): + ''' + Regression: searching a multi-operand compound with no operands must treat + its operands (op1/op2/op3) as free register slots, enumerated and unified + like REGn -- not leaked as literal names. gcf-ltc over a full gadget set + must still assemble (this returned nothing before the fix). + ''' + gadgets = [ + make_gadget(b'\x5a\xc3', 0x10), # pop rdx ; ret + make_gadget(b'\x48\x29\xcb\xc3', 0x20), # sub rbx, rcx ; ret + make_gadget(b'\x48\x11\xd0\xc3', 0x40), # adc rax, rdx ; ret + ] + step = {'op': 'gcf-ltc', 'op1': None, 'op2': None, 'data': 'gcf-ltc()'} + results = list(RopChain(GadFinder()).search(gadgets, [step])) + assert results + assert [g.text_repr for g in results[0]] == [ + 'pop rdx ; ret', 'sub rbx, rcx ; ret', 'adc rax, rdx ; ret'] diff --git a/tests/test_roplang_gadgets.py b/tests/test_roplang_gadgets.py new file mode 100644 index 0000000..e61a213 --- /dev/null +++ b/tests/test_roplang_gadgets.py @@ -0,0 +1,375 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +''' +Cross-architecture gadget-finding matrix for the ROPLang operations. + +For every implemented architecture (x86, x86-64, AArch64, RISC-V) this checks +that each ROPLang operation is realizable from gadgets: + + - a *primitive* operation (one that resolves to a single gadget) is exercised + by building a representative gadget and asserting Operation.filter_gadgets + matches it -- this is the actual gadget-finding path; + - a *compound* operation (a chain of several gadgets/sub-operations) cannot be + a single gadget, so it is checked to be available and to expose at least one + realization on that architecture. + +A completeness test guarantees every operation in rop3/roplang/*.yaml is +accounted for on every architecture, so the matrix cannot silently skip one. + +The RISC-V carry operations (eqc, ltc, gcf-eqc, gcf-ltc) are excluded: RISC-V +has no condition/carry flags, so the YAML marks them `available: false`. The +matrix asserts exactly that instead of trying to find them. +''' + +import glob +import os +import struct + +import capstone +import pytest + +import rop3 +import rop3.operation as operation +import rop3.parser as parser +from rop3.arch import arch_singleton +from rop3.archs.x86_arch import X86_Architecture, X64_Architecture +from rop3.gadget import Gadget +from conftest import make_operation + +# --- The set of all ROPLang operations, straight from the YAML directory ------ + +_ROPLANG_DIR = os.path.join(os.path.dirname(rop3.__file__), 'roplang') +ROPLANG_OPS = frozenset(os.path.basename(f)[:-5] + for f in glob.glob(os.path.join(_ROPLANG_DIR, '*.yaml'))) + +# Operations RISC-V cannot realize (no condition/carry flags). +RISCV_CARRY_OPS = frozenset({'eqc', 'ltc', 'gcf-eqc', 'gcf-ltc'}) + + +# --- Terminators and frame prologues/epilogues per architecture --------------- + +RET_X86 = b'\xc3' # ret +RET_A64 = bytes.fromhex('c0035fd6') # ret +LDP_A64 = bytes.fromhex('fd7bc1a8') # ldp x29, x30, [sp], #16 (frames the ret) +RET_RV = b'\x67\x80\x00\x00' # ret (jalr x0, 0(ra)) +LD_RA_RV = b'\x83\x30\x81\x00' # ld ra, 8(sp) (restores ra: frames the ret) + + +# --- RISC-V instruction encoders (R/I/S formats) ------------------------------ + +def _r(op, f3, f7, rd, rs1, rs2): + return struct.pack('> 5) & 0x7f) << 25) | (rs2 << 20) | (rs1 << 15) + | (f3 << 12) | ((imm & 0x1f) << 7) | op) + + +# --- Representative single-gadget realization of each primitive, per arch ------ +# +# Each entry is op -> (operands, body_bytes); the body is the operation's own +# instruction(s), wrapped by the architecture's terminator/frame at test time. + +X86_PRIMITIVES = { + 'add': (['eax', 'ebx'], b'\x01\xd8'), # add eax, ebx + 'sub': (['eax', 'ebx'], b'\x29\xd8'), # sub eax, ebx + 'and': (['eax', 'ebx'], b'\x21\xd8'), # and eax, ebx + 'or': (['eax', 'ebx'], b'\x09\xd8'), # or eax, ebx + 'xor': (['eax', 'ebx'], b'\x31\xd8'), # xor eax, ebx + 'neg': (['eax'], b'\xf7\xd8'), # neg eax + 'not': (['eax'], b'\xf7\xd0'), # not eax + 'inc': (['eax'], b'\x40'), # inc eax + 'mov': (['eax', 'ebx'], b'\x89\xd8'), # mov eax, ebx + 'ld': (['eax', 'ebx'], b'\x8b\x03'), # mov eax, [ebx] + 'st': (['ebx', 'eax'], b'\x89\x03'), # mov [ebx], eax + 'lc': (['eax'], b'\x58'), # pop eax + 'sc': (['eax'], b'\x50\x58'), # push eax ; pop eax +} + +X64_PRIMITIVES = { + 'add': (['rax', 'rbx'], b'\x48\x01\xd8'), # add rax, rbx + 'sub': (['rax', 'rbx'], b'\x48\x29\xd8'), # sub rax, rbx + 'and': (['rax', 'rbx'], b'\x48\x21\xd8'), # and rax, rbx + 'or': (['rax', 'rbx'], b'\x48\x09\xd8'), # or rax, rbx + 'xor': (['rax', 'rbx'], b'\x48\x31\xd8'), # xor rax, rbx + 'neg': (['rax'], b'\x48\xf7\xd8'), # neg rax + 'not': (['rax'], b'\x48\xf7\xd0'), # not rax + 'inc': (['rax'], b'\x48\xff\xc0'), # inc rax + 'mov': (['rax', 'rbx'], b'\x48\x89\xd8'), # mov rax, rbx + 'ld': (['rax', 'rbx'], b'\x48\x8b\x03'), # mov rax, [rbx] + 'st': (['rbx', 'rax'], b'\x48\x89\x03'), # mov [rbx], rax + 'lc': (['rax'], b'\x58'), # pop rax + 'sc': (['rax'], b'\x50\x58'), # push rax ; pop rax +} + +AARCH64_PRIMITIVES = { + 'add': (['x0', 'x1'], bytes.fromhex('0000018b')), # add x0, x0, x1 + 'sub': (['x0', 'x1'], bytes.fromhex('000001cb')), # sub x0, x0, x1 + 'and': (['x0', 'x1'], bytes.fromhex('0000018a')), # and x0, x0, x1 + 'or': (['x0', 'x1'], bytes.fromhex('000001aa')), # orr x0, x0, x1 + 'xor': (['x0', 'x1'], bytes.fromhex('000001ca')), # eor x0, x0, x1 + 'neg': (['x0'], bytes.fromhex('e00300cb')), # neg x0, x0 + 'not': (['x0'], bytes.fromhex('e00320aa')), # mvn x0, x0 + 'inc': (['x0'], bytes.fromhex('00040091')), # add x0, x0, #1 + 'mov': (['x0', 'x1'], bytes.fromhex('e00301aa')), # mov x0, x1 + 'ld': (['x0', 'x1'], bytes.fromhex('200040f9')), # ldr x0, [x1] + 'st': (['x0', 'x1'], bytes.fromhex('010000f9')), # str x1, [x0] + 'lc': (['x0'], bytes.fromhex('e00340f9')), # ldr x0, [sp] + 'sc': (['x0'], bytes.fromhex('e00300f9')), # str x0, [sp] +} + +RISCV_PRIMITIVES = { + 'add': (['a0', 'a1'], _r(0x33, 0, 0x00, 10, 10, 11)), # add a0, a0, a1 + 'sub': (['a0', 'a1'], _r(0x33, 0, 0x20, 10, 10, 11)), # sub a0, a0, a1 + 'and': (['a0', 'a1'], _r(0x33, 7, 0x00, 10, 10, 11)), # and a0, a0, a1 + 'or': (['a0', 'a1'], _r(0x33, 6, 0x00, 10, 10, 11)), # or a0, a0, a1 + 'xor': (['a0', 'a1'], _r(0x33, 4, 0x00, 10, 10, 11)), # xor a0, a0, a1 + 'neg': (['a0'], _r(0x33, 0, 0x20, 10, 0, 10)), # neg a0, a0 + 'not': (['a0'], _i(0x13, 4, 10, 10, -1)), # not a0, a0 + 'inc': (['a0'], _i(0x13, 0, 10, 10, 1)), # addi a0, a0, 1 + 'mov': (['a0', 'a1'], _i(0x13, 0, 10, 11, 0)), # mv a0, a1 + 'ld': (['a0', 'a1'], _i(0x03, 3, 10, 11, 0)), # ld a0, 0(a1) + 'st': (['a1', 'a0'], _s(0x23, 3, 11, 10, 0)), # sd a0, 0(a1) + 'lc': (['a0'], _i(0x03, 3, 10, 2, 16)), # ld a0, 16(sp) + 'sc': (['a0'], _s(0x23, 3, 2, 10, 0)), # sd a0, 0(sp) +} + + +# --- Architecture matrix ------------------------------------------------------ + +class ArchSpec: + ''' One architecture cell of the matrix. ''' + def __init__(self, id, make_arch, cs_arch, cs_mode, wrap, primitives, + excluded=frozenset(), skip=None): + self.id = id + self.make_arch = make_arch + self.cs_arch = cs_arch + self.cs_mode = cs_mode + self.wrap = wrap # body bytes -> full gadget bytes + self.primitives = primitives + self.excluded = excluded # ops that are unavailable here + self.skip = skip # reason string, or None + + def initialize(self): + arch_singleton.reset() + arch_singleton.initialize(self.make_arch()) + + def gadget(self, body): + code = self.wrap(body) + md = capstone.Cs(self.cs_arch, self.cs_mode) + md.detail = True + return Gadget(filename='t', arch=self.cs_arch, mode=self.cs_mode, + vaddr=0x1000, decodes=list(md.disasm(code, 0x1000)), bytes=code) + + +def _cs(name, default=None): + return getattr(capstone, name, default) + + +_HAS_ARM64 = hasattr(capstone, 'CS_ARCH_ARM64') +_HAS_RISCV = hasattr(capstone, 'CS_ARCH_RISCV') + +ARCHES = [ + ArchSpec('x86', X86_Architecture, + capstone.CS_ARCH_X86, capstone.CS_MODE_32, + lambda b: b + RET_X86, X86_PRIMITIVES), + ArchSpec('x64', X64_Architecture, + capstone.CS_ARCH_X86, capstone.CS_MODE_64, + lambda b: b + RET_X86, X64_PRIMITIVES), + ArchSpec('aarch64', + (lambda: __import__('rop3.archs.aarch64_arch', fromlist=['AArch64_Architecture']) + .AArch64_Architecture()), + _cs('CS_ARCH_ARM64'), _cs('CS_MODE_ARM'), + lambda b: b + LDP_A64 + RET_A64, AARCH64_PRIMITIVES, + skip=None if _HAS_ARM64 else 'capstone build without ARM64 support'), + ArchSpec('riscv', + (lambda: __import__('rop3.archs.riscv_arch', fromlist=['RISCV_Architecture']) + .RISCV_Architecture(compressed=True)), + _cs('CS_ARCH_RISCV'), + (_cs('CS_MODE_RISCV64', 0) | _cs('CS_MODE_RISCVC', 0)), + lambda b: LD_RA_RV + b + RET_RV, RISCV_PRIMITIVES, + excluded=RISCV_CARRY_OPS, + skip=None if _HAS_RISCV else 'capstone build without RISC-V support'), +] + +ARCH_BY_ID = {a.id: a for a in ARCHES} + +# Flat (arch, op) list for the primitive gadget-finding matrix. +PRIMITIVE_CASES = [ + pytest.param(spec.id, op, + marks=pytest.mark.skipif(bool(spec.skip), reason=spec.skip or ''), + id=f'{spec.id}-{op}') + for spec in ARCHES for op in sorted(spec.primitives) +] + +ARCH_CASES = [ + pytest.param(spec.id, + marks=pytest.mark.skipif(bool(spec.skip), reason=spec.skip or ''), + id=spec.id) + for spec in ARCHES +] + + +# --- Primitive gadget-finding: the operation matches a representative gadget --- + +@pytest.mark.parametrize('arch_id,op', PRIMITIVE_CASES) +def test_primitive_operation_is_found_as_gadget(arch_id, op): + spec = ARCH_BY_ID[arch_id] + spec.initialize() + operands, body = spec.primitives[op] + matched = make_operation(op, operands).filter_gadgets([spec.gadget(body)]) + assert matched, f'{op} not found on {arch_id}' + assert matched[0].op == op + + +# --- Completeness: every ROPLang op is accounted for on every architecture ----- + +@pytest.mark.parametrize('arch_id', ARCH_CASES) +def test_every_roplang_op_is_covered(arch_id): + spec = ARCH_BY_ID[arch_id] + spec.initialize() + + primitives = frozenset(spec.primitives) + compounds = ROPLANG_OPS - primitives - spec.excluded + + # the three buckets partition the whole ROPLang op set, with no overlap + assert primitives | compounds | spec.excluded == ROPLANG_OPS + assert not (primitives & spec.excluded) + + # every primitive listed for this arch really is a single-gadget op + for op in primitives: + defn = parser.Parser().get_op(op) + assert defn.available, f'{op} unexpectedly unavailable on {arch_id}' + assert any(r.is_single_gadget for r in defn.realizations), \ + f'{op} has no single-gadget realization on {arch_id}' + + # every compound op is available and assembles from at least one realization + for op in compounds: + defn = parser.Parser().get_op(op) + assert defn.available, f'{op} unexpectedly unavailable on {arch_id}' + assert defn.realizations, f'{op} has no realization on {arch_id}' + + # excluded ops are explicitly unavailable here + for op in spec.excluded: + defn = parser.Parser().get_op(op) + assert defn.available is False, f'{op} should be unavailable on {arch_id}' + assert defn.unavailable_reason + + +# --- Compound operations: the whole reuse chain is realizable ----------------- +# +# A compound operation (spa, sps, gsp, jmp, jmp-rel, lsd, eqc, ltc, gcf-*) is +# not a single gadget: it reuses one or more other operations via `operation:` +# steps (OpRef links). It can therefore never be found by the single-gadget +# path exercised above, so instead of searching for one gadget we verify that +# every operation it references exists, is available on this architecture, and +# -- followed transitively -- bottoms out in real, single-gadget primitives. + +def _op_refs(defn): + ''' Every OpRef (reused-operation step) across a definition's realizations. ''' + return [link for real in defn.realizations for link in real.links + if isinstance(link, operation.OpRef)] + + +def _is_compound(defn): + ''' A compound operation reuses at least one other operation, so it can + never be realized by a single gadget. ''' + return bool(_op_refs(defn)) + + +@pytest.mark.parametrize('arch_id', ARCH_CASES) +def test_compound_ops_resolve_to_available_primitives(arch_id): + ''' Counterpart to test_primitive_operation_is_found_as_gadget: for every + compound operation available on this architecture, walk its reuse chain + and assert each referenced operation is available and eventually reduces + to single-gadget primitives (no dangling reference, no cycle, no reuse + of an operation that is unavailable here). ''' + spec = ARCH_BY_ID[arch_id] + spec.initialize() + p = parser.Parser() + by_name = {defn.name: defn for defn in p.get_ops()} + + compounds = sorted(name for name, defn in by_name.items() + if _is_compound(defn) and defn.available) + assert compounds, f'no compound operations discovered on {arch_id}' + + def resolve(name, chain): + assert name in by_name, f'{chain[-1]} reuses unknown operation {name}' + assert name not in chain, f'reuse cycle on {arch_id}: {" -> ".join(chain + [name])}' + sub = by_name[name] + assert sub.available, \ + f'{chain[0]} on {arch_id} reuses unavailable operation {name}' + assert sub.realizations, f'{name} has no realization on {arch_id}' + for ref in _op_refs(sub): + resolve(ref.name, chain + [name]) + + for name in compounds: + defn = by_name[name] + assert defn.realizations, f'{name} has no realization on {arch_id}' + # A compound must reduce to primitives; assert every leaf of the reuse + # tree is a real single-gadget op (the recursion also catches cycles). + for ref in _op_refs(defn): + resolve(ref.name, [name]) + assert any(not r.is_single_gadget for r in defn.realizations), \ + f'{name} is classified compound but has only single-gadget realizations' + + +def test_primitive_tables_list_only_single_gadget_ops(): + ''' Regression guard for the split the two matrix halves rely on: an op in a + hand-written primitive table must be a genuine single-gadget op and + never a reuse-based compound -- so it belongs in the gadget-finding test, + not the compound test. spa/sps in particular are compounds (they reuse + add/sub on the stack pointer) and must stay out of the primitive tables, + which is exactly why they were removed from the per-arch pattern tests. ''' + for spec in ARCHES: + if spec.skip: + continue + spec.initialize() + by_name = {d.name: d for d in parser.Parser().get_ops()} + for op in spec.primitives: + defn = by_name[op] + assert defn.available, f'{op} unavailable on {spec.id}' + assert not _is_compound(defn), \ + f'{op} is a compound; drop it from the {spec.id} primitive table' + assert any(r.is_single_gadget for r in defn.realizations), \ + f'{op} has no single-gadget realization on {spec.id}' + for op in ('spa', 'sps'): + assert op not in spec.primitives, \ + f'{op} is a compound; it must not be in the {spec.id} primitive table' + assert _is_compound(by_name[op]), f'{op} should be compound on {spec.id}' + + +def test_matrix_covers_all_architectures(): + ''' The matrix must span every architecture rop3 implements. ''' + assert {spec.id for spec in ARCHES} == {'x86', 'x64', 'aarch64', 'riscv'} + + +def test_only_riscv_excludes_operations(): + ''' Carry operations are excluded on RISC-V and nowhere else. ''' + for spec in ARCHES: + if spec.id == 'riscv': + assert spec.excluded == RISCV_CARRY_OPS + else: + assert spec.excluded == frozenset() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..0d34654 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,214 @@ +''' +This file is part of rop3 (https://github.com/reverseame/rop3). + +rop3 is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +rop3 is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with rop3. If not, see . +''' + +import capstone +import pytest + +from rop3.search import galileo_scan, aligned_scan, framed_aligned_scan, _linear_disasm +from rop3.archs.x86_arch import X86_Architecture, X64_Architecture + +_riscv = pytest.mark.skipif(not hasattr(capstone, 'CS_ARCH_RISCV'), + reason='capstone build without RISC-V support') + + +def _x86_md(): + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64) + md.detail = True + return md + + +def _texts(gen): + out = {} + for vaddr, _raw, decodes in gen: + text = ' ; '.join(f'{d.mnemonic} {d.op_str}'.strip() for d in decodes) + out.setdefault(vaddr, set()).add(text) + return out + + +# --- scan strategy is architecture-dependent ------------------------------ + +def test_scan_name_and_parallelism_are_architecture_dependent(): + from rop3.archs.riscv_arch import RISCV_Architecture + from rop3.archs.aarch64_arch import AArch64_Architecture + # scan_name is a descriptive label; parallelizable gates chunked scanning. + assert X86_Architecture().scan_name == 'galileo' + assert X64_Architecture().scan_name == 'galileo' + assert RISCV_Architecture(compressed=True).scan_name == 'framed aligned' + assert AArch64_Architecture().scan_name == 'aligned' + # Only the Galileo backward walk is chunkable across worker processes. + assert X64_Architecture().parallelizable + assert not RISCV_Architecture(compressed=True).parallelizable + assert not AArch64_Architecture().parallelizable + + +# --- Galileo (backward from every offset) --------------------------------- + +def _galileo(opcodes, base, depth=5, alignment=1): + arch = X64_Architecture() + return _texts(galileo_scan(opcodes, base, arch.get_rop_terminations(), depth, + alignment, _x86_md().disasm, arch.is_valid_rop_gadget)) + + +def test_galileo_walks_backward_from_every_ret(): + res = _galileo(b'\x58\xc3\x5b\xc3', 0x1000) # pop rax;ret / pop rbx;ret + assert 'pop rax ; ret' in res[0x1000] + assert 'ret' in res[0x1001] + assert 'pop rbx ; ret' in res[0x1002] + assert 'ret' in res[0x1003] + + +def test_galileo_respects_depth_bound(): + assert _galileo(b'\x58\xc3', 0x1000, depth=1) == {0x1001: {'ret'}} + + +def test_galileo_rejects_intermediate_ret(): + res = _galileo(b'\x58\xc3\x5b\xc3', 0x1000) + assert 'pop rax ; ret ; pop rbx ; ret' not in res.get(0x1000, set()) + + +def test_galileo_alignment_filters_odd_starts(): + code = b'\xc3\x90\xc3\x90' # ret ; nop ; ret ; nop + assert any(v % 2 for v in _galileo(code, 0x1000, alignment=1)) + assert all(v % 2 == 0 for v in _galileo(code, 0x1000, alignment=2)) + + +def test_galileo_accept_match_partitions_terminations(): + arch = X64_Architecture() + kept = _texts(galileo_scan(b'\x58\xc3\x5b\xc3', 0x1000, arch.get_rop_terminations(), + 5, 1, _x86_md().disasm, arch.is_valid_rop_gadget, + accept_match=lambda ref: ref == 2)) + assert set(kept) == {0x1000, 0x1001} + + +# --- Aligned (intended-instruction linear sweep) -------------------------- + +# mov eax, 0xc3 ; ret -- the immediate carries a 0xc3 (ret) byte. +UNINTENDED = b'\xb8\xc3\x00\x00\x00\xc3' + + +def _aligned(opcodes, base, depth=8): + arch = X64_Architecture() + return _texts(aligned_scan(opcodes, base, depth, 1, _x86_md().disasm, + arch.is_valid_rop_gadget)) + + +def test_aligned_extracts_only_intended_instructions(): + res = _aligned(UNINTENDED, 0x1000, depth=8) + assert res[0x1000] == {'mov eax, 0xc3 ; ret'} + assert res[0x1005] == {'ret'} + assert set(res) == {0x1000, 0x1005} # nothing inside the mov imm + + +def test_aligned_respects_byte_depth(): + assert _aligned(UNINTENDED, 0x1000, depth=5) == {0x1005: {'ret'}} + + +def test_aligned_resyncs_past_undecodable_tail(): + # ret then a lone 0xb8 (truncated mov) which cannot decode. + assert _aligned(b'\xc3\xb8', 0x1000, depth=4) == {0x1000: {'ret'}} + + +def test_linear_disasm_is_program_order(): + insns = _linear_disasm(UNINTENDED, 0x1000, 1, _x86_md().disasm) + assert [i.mnemonic for i in insns] == ['mov', 'ret'] + assert [i.address for i in insns] == [0x1000, 0x1005] + + +@_riscv +def test_aligned_equals_galileo_on_fixed_width(): + from rop3.archs.riscv_arch import RISCV_Architecture + arch = RISCV_Architecture() # non-compressed: 4-byte aligned + md = capstone.Cs(capstone.CS_ARCH_RISCV, capstone.CS_MODE_RISCV64) + md.detail = True + code = b'\x33\x85\xc5\x00' + b'\x93\x06\x07\x00' + b'\x67\x80\x00\x00' # add;mv;ret + + def keys(gen): + return {(v, r.hex()) for v, r, _ in gen} + + galileo = keys(galileo_scan(code, 0x1000, arch.get_rop_terminations(), 16, 4, + md.disasm, arch.is_valid_rop_gadget)) + aligned = keys(aligned_scan(code, 0x1000, 16, 4, md.disasm, arch.is_valid_rop_gadget)) + assert aligned == galileo + assert len(aligned) == 3 + + +# --- RISC-V (only ra-restoring ROP gadgets) ------------------------------- + +# ld ra,8(sp)=83 30 81 00 ; add a0,a1,a2=33 85 c5 00 ; addi sp,sp,16=13 01 01 01 +# ld ra,8(a0)=83 30 85 00 ; ret=67 80 00 00 ; c.ldsp ra,8(sp)=a2 60 +LD_RA_SP = b'\x83\x30\x81\x00' +LD_RA_A0 = b'\x83\x30\x85\x00' +ADD = b'\x33\x85\xc5\x00' +ADDI_SP = b'\x13\x01\x01\x01' +RET = b'\x67\x80\x00\x00' + + +def _riscv_texts(code, base=0x1000, depth=16, compressed=True): + from rop3.archs.riscv_arch import RISCV_Architecture + arch = RISCV_Architecture(compressed=compressed) + mode = capstone.CS_MODE_RISCV64 | (capstone.CS_MODE_RISCVC if compressed else 0) + md = capstone.Cs(capstone.CS_ARCH_RISCV, mode) + md.detail = True + return _texts(framed_aligned_scan(code, base, depth, arch.alignment, md.disasm, + arch.is_valid_rop_gadget, arch.is_frame_load, + arch.is_return)) + + +@_riscv +def test_riscv_scan_keeps_ra_restoring_gadget(): + res = _riscv_texts(LD_RA_SP + RET) + assert res == {0x1000: {'ld ra, 8(sp) ; ret'}} # bare ret at 0x1004 dropped + + +@_riscv +def test_riscv_scan_drops_gadget_without_ra_load(): + assert _riscv_texts(ADD + RET) == {} # no ra restore -> nothing + + +@_riscv +def test_riscv_scan_requires_stack_source_not_just_ra(): + assert _riscv_texts(LD_RA_A0 + RET) == {} # ra loaded, but from a0 + + +@_riscv +def test_riscv_scan_ra_load_may_precede_other_instructions(): + res = _riscv_texts(LD_RA_SP + ADDI_SP + RET) + assert res == {0x1000: {'ld ra, 8(sp) ; addi sp, sp, 0x10 ; ret'}} + + +@_riscv +def test_riscv_scan_compressed_ra_load(): + res = _riscv_texts(b'\xa2\x60' + RET) # c.ldsp ra, 8(sp) ; ret + assert res == {0x1000: {'c.ldsp ra, 8(sp) ; ret'}} + + +@_riscv +def test_riscv_is_ra_load_predicate(): + from rop3.archs.riscv_arch import RISCV_Architecture + arch = RISCV_Architecture(compressed=True) + md = capstone.Cs(capstone.CS_ARCH_RISCV, + capstone.CS_MODE_RISCV64 | capstone.CS_MODE_RISCVC) + md.detail = True + + def is_ra_load(code): + return arch.is_ra_load(list(md.disasm(code, 0x1000))[0]) + + assert is_ra_load(LD_RA_SP) # ld ra, 8(sp) + assert is_ra_load(b'\xa2\x60') # c.ldsp ra, 8(sp) + assert not is_ra_load(LD_RA_A0) # ld ra, 8(a0) -- not the stack + assert not is_ra_load(b'\x03\x35\x81\x00') # ld a0, 8(sp) -- not ra + assert not is_ra_load(ADD) # not a load diff --git a/tests/test_utils.py b/tests/test_utils.py index 49ae113..46819f2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -23,20 +23,20 @@ def test_pretty_addr_padding(): # padding is the total field width including the '0x' prefix - assert utils.pretty_addr(0x1000, capstone.CS_MODE_32) == '0x001000' - assert utils.pretty_addr(0x1000, capstone.CS_MODE_64) == '0x00000000001000' - assert len(utils.pretty_addr(0x1000, capstone.CS_MODE_32)) == 8 - assert len(utils.pretty_addr(0x1000, capstone.CS_MODE_64)) == 16 + assert utils.pretty_addr(0x1000, 4) == '0x001000' + assert utils.pretty_addr(0x1000, 8) == '0x00000000001000' + assert len(utils.pretty_addr(0x1000, 4)) == 8 + assert len(utils.pretty_addr(0x1000, 8)) == 16 def test_pack_addr_endianness_and_width(): - assert utils.pack_addr(0x41424344, capstone.CS_MODE_32) == b'\x44\x43\x42\x41' - assert utils.pack_addr(0x41424344, capstone.CS_MODE_64) == \ + assert utils.pack_addr(0x41424344, 4) == b'\x44\x43\x42\x41' + assert utils.pack_addr(0x41424344, 8) == \ b'\x44\x43\x42\x41\x00\x00\x00\x00' @pytest.mark.parametrize('fn', [utils.pretty_addr, utils.pack_addr]) -def test_addr_helpers_reject_unknown_mode(fn): - ''' Regression for issue #15: unbound local on unsupported mode. ''' +def test_addr_helpers_reject_unknown_size(fn): + ''' Regression for issue #15: unbound local on unsupported address size. ''' with pytest.raises(ValueError): - fn(0x1000, mode=999) + fn(0x1000, size=999)