diff --git a/README.md b/README.md index 532114d..7af72c2 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ Now, you can install dependencies in [requirements.txt](requirements.txt): ``` 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 [ ...]] [--keep-canary-address] [--base [ ...]] [--op ] [--dst ] [--src ] [--ropchain ] - [--exhaustive | --no-exhaustive] + [--binary [ ...]] [--badchar [ ...]] [--badchar-bytes [ ...]] [--keep-canary-address] [--base [ ...]] [--arch ] [--symbols] + [--output {text,json,csv}] [--op ] [--dst ] [--src ] [--ropchain ] [--exhaustive | --no-exhaustive] This tool allows you to search for gadgets, operations, and ROP chains using a backtracking algorithm in a tree-like structure @@ -56,10 +56,16 @@ options: specify a list of binary path files to analyze --badchar [ ...] specify a list of chars to avoid in gadget address + --badchar-bytes [ ...] + specify a list of chars to avoid in gadget opcode bytes --keep-canary-address do not prefer canary-free addresses (0x00, 0x0a, 0x0d, 0xff) when discarding duplicate gadgets --base [ ...] specify a base address to relocate binary files (it may take a while). When you specify more than one base address, you need to provide one address for each binary + --arch select the architecture slice of a fat Mach-O binary (e.g. x86_64, i386) + --symbols annotate gadgets with the nearest symbol (when the binary is not stripped) + --output {text,json,csv} + output format (default: text) --op search for operation --dst specify a destination register for the operation --src specify a source register for the operation diff --git a/rop3/__init__.py b/rop3/__init__.py index 337a318..c9be26d 100644 --- a/rop3/__init__.py +++ b/rop3/__init__.py @@ -41,25 +41,30 @@ def main(): if args.ropchain: ropchain = rop3.ropchain.RopChain(finder) result = ropchain.search_from_files(args.binary, args.ropchain, - base=args.base, badchars=args.badchar) - if args.exhaustive: - for idx, x in enumerate(result, 1): - utils.print_ropchain(x, idx) - else: - utils.print_ropchain(next(iter(result))) + base=args.base, badchars=args.badchar, + badchar_bytes=args.badchar_bytes, + arch=args.arch, symbols=args.symbols) + utils.output_ropchains(result, args.output, exhaustive=args.exhaustive) elif args.op: - - result = finder.find_op(args.binary, args.op, args.dst, args.src, base=args.base, badchars=args.badchar) + result = finder.find_op(args.binary, args.op, args.dst, args.src, + base=args.base, badchars=args.badchar, + badchar_bytes=args.badchar_bytes, + arch=args.arch, symbols=args.symbols) if result and isinstance(result[0], list): - for chain in result: - for gadget in chain: - utils.print_gadget(gadget) + ''' Composite operation: a list of chains ''' + if args.output == 'text': + for chain in result: + for gadget in chain: + utils.print_gadget(gadget) + else: + utils.output_ropchains(result, args.output, exhaustive=True) else: - for gadget in result: - utils.print_gadget(gadget) + utils.output_gadgets(result, args.output) else: - for gadget in finder.find(args.binary, base=args.base, badchars=args.badchar): - utils.print_gadget(gadget) + gadgets = finder.find(args.binary, base=args.base, badchars=args.badchar, + badchar_bytes=args.badchar_bytes, + arch=args.arch, symbols=args.symbols) + utils.output_gadgets(gadgets, args.output) except parser.ParserException as exc: debug.error(str(exc)) except rop3.ropchain.RopChainNotFound as exc: diff --git a/rop3/args.py b/rop3/args.py index b42870f..9e0deef 100644 --- a/rop3/args.py +++ b/rop3/args.py @@ -39,8 +39,12 @@ def __init__(self): 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') + self.argparser.add_argument('--badchar-bytes', type=str, metavar='', nargs='+', help='specify a list of chars to avoid in gadget opcode bytes') self.argparser.add_argument('--keep-canary-address', action='store_true', default=False, help='do not prefer canary-free addresses (0x00, 0x0a, 0x0d, 0xff) when discarding duplicate gadgets') self.argparser.add_argument('--base', type=str, metavar='', nargs='+', help='specify a base address to relocate binary files (it may take a while). When you specify more than one base address, you need to provide one address for each binary') + 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('--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') @@ -108,8 +112,10 @@ def _check_args(self, args): for baddr in args.base: self._check_int_value(baddr) - if args.badchar: - for badchar in args.badchar: + for option in (args.badchar, args.badchar_bytes): + if not option: + continue + for badchar in option: value = self._check_int_value(badchar) if value < 0x00 or value > 0xff: debug.error(f'{badchar}: bad char must be one byte (range 0x00-0xff)') diff --git a/rop3/binaries/elf.py b/rop3/binaries/elf.py index 19ffc15..e487fad 100644 --- a/rop3/binaries/elf.py +++ b/rop3/binaries/elf.py @@ -19,6 +19,7 @@ import io from elftools.elf.elffile import ELFFile, ELFError +from elftools.elf.sections import SymbolTableSection import rop3.binary as binary @@ -74,6 +75,19 @@ def get_exec_sections(self): }) return ret + def get_symbols(self): + ''' Function/object symbols from .symtab and .dynsym, rebased by the + same delta as the sections. Stripped binaries yield none. ''' + ret = [] + for sec in self._elf.iter_sections(): + if not isinstance(sec, SymbolTableSection): + continue + for sym in sec.iter_symbols(): + addr = sym['st_value'] + if sym.name and addr: + ret.append((addr + self._base_delta, sym.name)) + return ret + def get_arch(self): return self._arch diff --git a/rop3/binaries/macho.py b/rop3/binaries/macho.py index 798089e..1dee7e4 100644 --- a/rop3/binaries/macho.py +++ b/rop3/binaries/macho.py @@ -17,11 +17,12 @@ import capstone import io +import struct from macholib.MachO import MachO as _MachO from macholib.mach_o import ( - LC_SEGMENT, LC_SEGMENT_64, - CPU_TYPE_NAMES, + LC_SEGMENT, LC_SEGMENT_64, LC_SYMTAB, + CPU_TYPE_NAMES, N_STAB, S_ATTR_PURE_INSTRUCTIONS, S_ATTR_SOME_INSTRUCTIONS ) @@ -32,8 +33,14 @@ VM_PROT_EXECUTE = 0x04 S_INSTRUCTION_ATTRS = S_ATTR_PURE_INSTRUCTIONS | S_ATTR_SOME_INSTRUCTIONS +# Mach-O architecture name -> (rop3 architecture class) +SUPPORTED_ARCHS = { + 'x86_64': X64_Architecture, + 'i386': X86_Architecture, +} + class MachO: - def __init__(self, data, base): + def __init__(self, data, base, arch=None): # Dirty way to initialize the class, since it only supports reading # from a filename try: @@ -46,24 +53,42 @@ def __init__(self, data, base): self._macho.load(self._file) except Exception as exc: raise binary.BinaryException(str(exc)) from exc - # Fat binaries carry several headers; pick the first supported slice - # (do not commit to a header until its architecture is recognised) - self._header = None - self._arch = None - for header in self._macho.headers: - arch = CPU_TYPE_NAMES.get(header.header.cputype) - if arch == "x86_64": - self._header, self._arch = header, X64_Architecture() - break - elif arch == "i386": - self._header, self._arch = header, X86_Architecture() - break - if not self._header: - raise binary.BinaryException( - 'Mach-O: No supported architectures were found') + + self._header, self._arch = self._select_slice(arch) self._base_delta = self._base_delta_for(base) + def _select_slice(self, arch): + ''' + Fat binaries carry several headers. With --arch, pick that slice; else + pick the first supported slice in file order. Do not commit to a header + until its architecture is recognized. + ''' + available = {} # arch name -> header (first occurrence) + for header in self._macho.headers: + name = CPU_TYPE_NAMES.get(header.header.cputype) + if name is not None: + available.setdefault(name, header) + + if arch is not None: + if arch not in SUPPORTED_ARCHS: + raise binary.BinaryException( + f'Mach-O: unsupported --arch {arch} ' + f'(choose from {", ".join(sorted(SUPPORTED_ARCHS))})') + if arch not in available: + present = ", ".join(sorted(available)) or "none" + raise binary.BinaryException( + f'Mach-O: arch {arch} not present in binary (available: {present})') + return available[arch], SUPPORTED_ARCHS[arch]() + + for header in self._macho.headers: + name = CPU_TYPE_NAMES.get(header.header.cputype) + if name in SUPPORTED_ARCHS: + return header, SUPPORTED_ARCHS[name]() + + raise binary.BinaryException( + 'Mach-O: No supported architectures were found') + def _image_base(self): ''' Link-time base of the image: the __TEXT segment vmaddr (fall back to the lowest segment vmaddr) ''' @@ -102,6 +127,36 @@ def get_exec_sections(self): }) return ret + def get_symbols(self): + ''' Symbols from the LC_SYMTAB symbol table, rebased by the same delta + 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 + entry_fmt = ' list[Gadget]: - ''' base is normalised to one entry per binary by the argument parser ''' + def find(self, filenames: list[str], base=None, badchars=None, + badchar_bytes=None, arch=None, symbols=False) -> list[Gadget]: + ''' base is normalized to one entry per binary by the argument parser ''' bases = base if isinstance(base, list) else [base] * len(filenames) avoid = self._avoid_bytes(badchars) if not self._keep_duplicates(): seen: dict = {} for filename, file_base in zip(filenames, bases): - binary = self._open_binary(filename, file_base) + binary = self._open_binary(filename, file_base, arch) + symtab = self._symbol_table(binary) if symbols else None before = len(seen) total = 0 - for gadget in self._search_gadgets(binary, badchars): + for gadget in self._search_gadgets(binary, badchars, badchar_bytes, symtab): total += 1 existing = seen.get(gadget) if existing is None: @@ -86,8 +89,9 @@ def find(self, filenames: list[str], base=None, badchars=None) -> list[Gadget]: else: gadgets = [] for filename, file_base in zip(filenames, bases): - binary = self._open_binary(filename, file_base) - gadgets.extend(self._search_gadgets(binary, badchars)) + binary = self._open_binary(filename, file_base, arch) + symtab = self._symbol_table(binary) if symbols else None + gadgets.extend(self._search_gadgets(binary, badchars, badchar_bytes, symtab)) return self._sort_gadgets(gadgets) def _avoid_bytes(self, badchars) -> set: @@ -105,16 +109,34 @@ def _addr_canary_score(self, gadget: Gadget, avoid: set) -> int: def _sort_gadgets(self, gadgets: list[Gadget]) -> list[Gadget]: return sorted(gadgets, key=lambda g: (os.path.basename(g.filename), g.vaddr)) - def _open_binary(self, filename, base): - binary = rop3.binary.Binary(filename, base) - arch = binary.get_arch() - if arch_singleton.is_initialized() and not arch_singleton.matches(arch): + def _open_binary(self, filename, base, arch=None): + binary = rop3.binary.Binary(filename, base, arch) + binary_arch = binary.get_arch() + 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(arch) + arch_singleton.initialize(binary_arch) return binary - def find_op(self, filenames, op, dst=None, src=None, base=None, badchars=None): - gadgets = self.find(filenames, base, badchars) + def _symbol_table(self, binary): + ''' Sorted (address, name) pairs and a parallel address list for the + nearest-symbol bisect (see _nearest_symbol). ''' + symbols = sorted(binary.get_symbols()) + addrs = [addr for addr, _ in symbols] + return (addrs, symbols) + + def _nearest_symbol(self, vaddr, symbol_table): + ''' Name (with byte offset) of the closest symbol at or below vaddr. ''' + addrs, symbols = symbol_table + idx = bisect.bisect_right(addrs, vaddr) - 1 + if idx < 0: + return None + sym_addr, name = symbols[idx] + 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, + 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) def find_op_from_gadgets(self, gadgets, op, dst=None, src=None): @@ -132,7 +154,7 @@ def find_op_from_gadgets(self, gadgets, op, dst=None, src=None): op_obj = operation.Operation(op, dst, src) return op_obj.filter_gadgets(gadgets) - def _search_gadgets(self, binary, badchars): + def _search_gadgets(self, binary, badchars, badchar_bytes=None, symbol_table=None): sections = binary.get_exec_sections() arch = arch_singleton.arch.arch mode = arch_singleton.arch.mode @@ -155,15 +177,20 @@ def _search_gadgets(self, binary, badchars): 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): + symbol = self._nearest_symbol(vaddr, symbol_table) \ + if symbol_table else None yield Gadget( filename=binary.filename, arch=arch, mode=mode, vaddr=vaddr, decodes=decodes, - bytes=bytes_ + bytes=bytes_, + symbol=symbol, ) def _gad_terminations(self): @@ -227,6 +254,14 @@ def _is_valid_address(self, vaddr, badchars, arch_mode): return True vaddr = utils.pack_addr(vaddr, arch_mode) - + return not any([bytes([int(badchar, 0)]) in vaddr for badchar in badchars]) + def _is_valid_bytes(self, gadget_bytes, badchar_bytes): + ''' Reject gadgets whose opcode bytes contain a forbidden byte (#21). ''' + if not badchar_bytes: + return True + + forbidden = {int(b, 0) for b in badchar_bytes} + return not any(byte in forbidden for byte in gadget_bytes) + diff --git a/rop3/gadget.py b/rop3/gadget.py index e3d9e7e..d67bd33 100644 --- a/rop3/gadget.py +++ b/rop3/gadget.py @@ -45,6 +45,7 @@ class Gadget: op: str = None dst: str = None src: str = None + symbol: str = None side_regs: set[str] = field(init=False, default_factory=set) side_mem: set[str] = field(init=False, default_factory=set) @@ -104,6 +105,8 @@ def __repr__(self) -> str: def __str__(self) -> str: ret = f"[{os.path.basename(self.filename)} @ {hex(self.vaddr)}]: " ret += self.text_repr + if self.symbol: + ret += f" <{self.symbol}>" if self.count and self.count > 1: ret += f" (x{self.count})" side_regs = list(self.side_regs) @@ -113,6 +116,24 @@ def __str__(self) -> str: return ret + def to_dict(self) -> dict: + ''' Serializable representation for machine-readable output. ''' + return { + 'file': os.path.basename(self.filename), + 'vaddr': hex(self.vaddr), + 'gadget': self.text_repr, + 'instructions': [ + f'{d.mnemonic} {d.op_str}'.strip() for d in self.decodes + ], + 'bytes': self.bytes.hex() if self.bytes is not None else None, + 'count': self.count, + 'symbol': self.symbol, + 'op': self.op, + 'dst': self.dst, + 'src': self.src, + 'modifies': sorted(self.side_regs), + } + def heuristic_basic_count(gadget: "Gadget") -> int: """ Cost function — lower is better: diff --git a/rop3/ropchain.py b/rop3/ropchain.py index b62799a..4063ee5 100644 --- a/rop3/ropchain.py +++ b/rop3/ropchain.py @@ -52,8 +52,10 @@ class RopChain: def __init__(self, gadfinder): self.gadfinder = gadfinder - def search_from_files(self, binaries: list[str], ropfile, base=None, badchars=None) -> Iterator[list[Gadget]]: - gadgets = self.gadfinder.find(binaries, base=base, badchars=badchars) + def search_from_files(self, binaries: list[str], ropfile, base=None, badchars=None, + badchar_bytes=None, arch=None, symbols=False) -> Iterator[list[Gadget]]: + gadgets = self.gadfinder.find(binaries, base=base, badchars=badchars, + badchar_bytes=badchar_bytes, arch=arch, symbols=symbols) return self.search_from_gadgets(gadgets, ropfile) def search_from_gadgets(self, gadgets, ropfile) -> Iterator[list[Gadget]]: @@ -148,7 +150,7 @@ def _build_per_comb_gadgets( Returns an array indexed [comb_idx][step_idx]. Each operation's gadget list is sorted once up front, and the - filter+prune result is memoised per (step, req_dst, req_src): different + 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. """ @@ -256,7 +258,7 @@ 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 normalised concrete reg. + dict mapping every abstract-register name to a normalized concrete reg. """ (state, ops_gadgets, op_pairs) = self._get_initial_state(gadgets) combinations = self._traverse(state, op_pairs) diff --git a/rop3/utils.py b/rop3/utils.py index 55bdda3..55239b6 100644 --- a/rop3/utils.py +++ b/rop3/utils.py @@ -16,6 +16,9 @@ ''' import os +import sys +import json +import csv import struct import __main__ import capstone @@ -63,6 +66,55 @@ def print_ropchain(ropchain, idx=None): if idx is not None: print() +# Columns used for the CSV output (list fields are space-joined) +_CSV_COLUMNS = ['file', 'vaddr', 'gadget', 'bytes', 'count', + 'symbol', 'op', 'dst', 'src', 'modifies'] + +def _csv_record(gadget): + record = gadget.to_dict() + record['modifies'] = ' '.join(record['modifies']) + return record + +def output_gadgets(gadgets, fmt='text'): + ''' Emit a flat list of gadgets in the requested format. ''' + if fmt == 'json': + print(json.dumps([g.to_dict() for g in gadgets], indent=2)) + elif fmt == 'csv': + writer = csv.DictWriter(sys.stdout, fieldnames=_CSV_COLUMNS, + extrasaction='ignore') + writer.writeheader() + for gadget in gadgets: + writer.writerow(_csv_record(gadget)) + else: + for gadget in gadgets: + print(gadget) + +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: + first = next(iter(chains), None) + if first is not None: + print_ropchain(first) + return + + chains = list(chains) + if fmt == 'json': + print(json.dumps([[g.to_dict() for g in chain] for chain in chains], indent=2)) + elif fmt == 'csv': + writer = csv.DictWriter(sys.stdout, fieldnames=['chain'] + _CSV_COLUMNS, + extrasaction='ignore') + writer.writeheader() + for idx, chain in enumerate(chains, 1): + for gadget in chain: + record = _csv_record(gadget) + record['chain'] = idx + writer.writerow(record) + else: + for idx, chain in enumerate(chains, 1): + print_ropchain(chain, idx) + def warning_text(text): return f'{WARNING_COLOR}{text}{END_COLOR}' diff --git a/tests/conftest.py b/tests/conftest.py index 8dcc0c4..323eb4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,74 +73,104 @@ def make_gadget(code: bytes, vaddr: int, mode=capstone.CS_MODE_64, ET_EXEC = 2 ET_DYN = 3 SHT_PROGBITS = 1 +SHT_SYMTAB = 2 SHT_STRTAB = 3 SHF_ALLOC = 0x2 SHF_EXECINSTR = 0x4 +STB_GLOBAL = 1 +STT_FUNC = 2 def build_minimal_elf(elfclass: int, machine: int, text_bytes: bytes, - text_addr: int, e_type: int = ET_DYN) -> bytes: + text_addr: int, e_type: int = ET_DYN, symbols=None) -> 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 - `.shstrtab`. Enough to exercise architecture detection, executable-section - extraction and --base relocation. ET_DYN keeps the image base at 0. + `.shstrtab`. With `symbols` (a list of (name, value)), it also emits a + `.symtab`/`.strtab` pair. Enough to exercise architecture detection, + executable-section extraction, --base relocation and symbol parsing. + ET_DYN keeps the image base at 0. ''' is64 = elfclass == 64 - shstrtab = b'\x00.text\x00.shstrtab\x00' - name_text = shstrtab.index(b'.text\x00') - name_shstr = shstrtab.index(b'.shstrtab\x00') + symbols = symbols or [] + + def section(name, stype, flags, addr, offset, size, link=0, entsize=0): + if is64: + return struct.pack(' bytes c3 + buf[0x20:0x22] = b'\x5d\xc3' # pop ebp ; ret -> bytes 5d c3 + gadgets = _run_find(gadfinder.ROP, buf, base, badchar_bytes=['0x5d']) + reprs = {g.text_repr for g in gadgets} + assert 'ret' in reprs + assert 'pop ebp ; ret' not in reprs + assert all(0x5d not in g.bytes for g in gadgets) + + +def test_symbol_annotation(x86): + ''' Issue #22: gadgets get the nearest symbol at or below their address. ''' + base = 0x10000 + buf = bytearray(0x40) + buf[0x10:0x12] = b'\x90\xc3' # nop ; ret -> gadget at 0x10010 + buf[0x30:0x32] = b'\x58\xc3' # pop eax ; ret -> gadget at 0x10030 + symbols = [(0x10000, 'start'), (0x10020, 'middle')] + gadgets = _run_find(gadfinder.ROP, buf, base, symbols=True, symbols_table=symbols) + by_text = {g.text_repr: g.symbol for g in gadgets} + assert by_text['nop ; ret'] == 'start+0x10' # nearest <= 0x10010 + assert by_text['pop eax ; ret'] == 'middle+0x10' # nearest <= 0x10030 + + +def test_symbol_annotation_exact_address(x86): + base = 0x10000 + buf = bytearray(0x40) + buf[0x0] = 0xc3 # ret exactly at symbol address 0x10000 + gadgets = _run_find(gadfinder.ROP, buf, base, symbols=True, + symbols_table=[(0x10000, 'start')]) + assert gadgets[0].symbol == 'start' # no +offset when offset is 0 diff --git a/tests/test_gadget.py b/tests/test_gadget.py index 7a11ef3..a1a2753 100644 --- a/tests/test_gadget.py +++ b/tests/test_gadget.py @@ -82,3 +82,24 @@ def test_colorize_honors_tty_and_no_color(monkeypatch): assert '\033' in gadget_mod._colorize('x') monkeypatch.setenv('NO_COLOR', '1') assert '\033' not in gadget_mod._colorize('x') + + +def test_to_dict(x64): + g = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret + g.count = 3 + g.symbol = 'main+0x4' + d = g.to_dict() + assert d['file'] == 'test' + assert d['vaddr'] == '0x1000' + assert d['gadget'] == 'pop rax ; ret' + assert d['instructions'] == ['pop rax', 'ret'] + assert d['bytes'] == '58c3' + assert d['count'] == 3 + assert d['symbol'] == 'main+0x4' + assert d['modifies'] == [] + + +def test_str_includes_symbol(x64): + g = make_gadget(b'\xc3', 0x1000) + g.symbol = 'func+0x10' + assert '' in str(g) diff --git a/tests/test_macho.py b/tests/test_macho.py new file mode 100644 index 0000000..d95c877 --- /dev/null +++ b/tests/test_macho.py @@ -0,0 +1,59 @@ +''' +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 os + +import pytest + +import rop3.binaries.macho as machomod +import rop3.binary as binary +from rop3.archs.x86_arch import 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)') + + +def _data(): + with open(FAT, 'rb') as f: + return f.read() + + +def test_default_slice_is_x86_64(): + assert isinstance(machomod.MachO(_data(), None).get_arch(), X64_Architecture) + + +def test_explicit_arch_x86_64(): + assert isinstance(machomod.MachO(_data(), None, 'x86_64').get_arch(), X64_Architecture) + + +def test_unsupported_arch_raises(): + with pytest.raises(binary.BinaryException): + machomod.MachO(_data(), None, 'arm64') + + +def test_absent_arch_raises(): + with pytest.raises(binary.BinaryException): + machomod.MachO(_data(), None, 'i386') # not present in /bin/ls + + +def test_get_symbols_returns_list(): + syms = machomod.MachO(_data(), None).get_symbols() + assert isinstance(syms, list) + assert all(isinstance(a, int) and isinstance(n, str) for a, n in syms) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..6a090e0 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,72 @@ +''' +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 csv +import io +import json + +import rop3.utils as utils + +from conftest import make_gadget + + +def test_output_gadgets_json(x64, capsys): + gadgets = [make_gadget(b'\x58\xc3', 0x1000), make_gadget(b'\x5b\xc3', 0x1010)] + utils.output_gadgets(gadgets, 'json') + data = json.loads(capsys.readouterr().out) + assert [g['vaddr'] for g in data] == ['0x1000', '0x1010'] + assert data[0]['gadget'] == 'pop rax ; ret' + + +def test_output_gadgets_csv(x64, capsys): + gadgets = [make_gadget(b'\x58\xc3', 0x1000)] + utils.output_gadgets(gadgets, 'csv') + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + assert rows[0]['vaddr'] == '0x1000' + assert rows[0]['gadget'] == 'pop rax ; ret' + assert rows[0]['bytes'] == '58c3' + + +def test_output_gadgets_text(x64, capsys, monkeypatch): + monkeypatch.setattr('sys.stdout.isatty', lambda: False, raising=False) + utils.output_gadgets([make_gadget(b'\x58\xc3', 0x1000)], 'text') + out = capsys.readouterr().out + assert 'pop rax ; ret' in out + assert '\033' not in out + + +def test_output_ropchains_json(x64, capsys): + chain = [make_gadget(b'\x58\xc3', 0x1000), make_gadget(b'\x5b\xc3', 0x1010)] + utils.output_ropchains([chain], 'json', exhaustive=True) + data = json.loads(capsys.readouterr().out) + assert len(data) == 1 and len(data[0]) == 2 + + +def test_output_ropchains_csv_has_chain_index(x64, capsys): + chain = [make_gadget(b'\x58\xc3', 0x1000)] + utils.output_ropchains([chain], 'csv', exhaustive=True) + rows = list(csv.DictReader(io.StringIO(capsys.readouterr().out))) + assert rows[0]['chain'] == '1' + + +def test_output_ropchains_text_non_exhaustive_takes_first(x64, capsys): + ''' Non-exhaustive text output consumes only the first chain (lazy). ''' + def gen(): + yield [make_gadget(b'\x58\xc3', 0x1000)] + raise AssertionError('second chain should not be consumed') + utils.output_ropchains(gen(), 'text', exhaustive=False) + assert 'pop rax ; ret' in capsys.readouterr().out diff --git a/tests/test_parser.py b/tests/test_parser.py index 0d22c9e..a7e6a76 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -33,7 +33,7 @@ def test_get_op_unknown_raises(x64): parser.Parser().get_op('definitely_not_an_op') -def test_composite_op_is_recognised(x64): +def test_composite_op_is_recognized(x64): ''' lsd is a composite (compose:) operation. ''' resolved = parser.Parser().get_op('lsd') assert isinstance(resolved, parser.CompositeOperation)