diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e2f82c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.11', '3.12', '3.13'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run test suite + run: pytest diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3818f5d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +pythonpath = . +addopts = -ra diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c941202 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest >= 7.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..971886e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,146 @@ +''' +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 struct + +import capstone +import pytest + +from rop3.arch import arch_singleton +from rop3.archs.x86_arch import X86_Architecture, X64_Architecture +from rop3.gadget import Gadget + + +@pytest.fixture(autouse=True) +def reset_arch(): + ''' + The architecture is a process-global singleton; reset it around every + test so cases are independent and can pick their own architecture. + ''' + arch_singleton._arch = None + yield + arch_singleton._arch = None + + +@pytest.fixture +def x64(): + arch_singleton._arch = None + arch_singleton.initialize(X64_Architecture()) + return arch_singleton.arch + + +@pytest.fixture +def x86(): + arch_singleton._arch = None + arch_singleton.initialize(X86_Architecture()) + return arch_singleton.arch + + +def make_gadget(code: bytes, vaddr: int, mode=capstone.CS_MODE_64, + filename='test') -> Gadget: + ''' Build a Gadget by disassembling raw bytes with capstone. ''' + md = capstone.Cs(capstone.CS_ARCH_X86, mode) + md.detail = True + decodes = list(md.disasm(code, vaddr)) + return Gadget( + filename=filename, + arch=capstone.CS_ARCH_X86, + mode=mode, + vaddr=vaddr, + decodes=decodes, + bytes=code, + ) + + +# --- Minimal in-memory ELF builder (no committed binary blobs) ------------ + +EM_386 = 3 +EM_X86_64 = 62 +ET_EXEC = 2 +ET_DYN = 3 +SHT_PROGBITS = 1 +SHT_STRTAB = 3 +SHF_ALLOC = 0x2 +SHF_EXECINSTR = 0x4 + + +def build_minimal_elf(elfclass: int, machine: int, text_bytes: bytes, + text_addr: int, e_type: int = ET_DYN) -> 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. + ''' + is64 = elfclass == 64 + shstrtab = b'\x00.text\x00.shstrtab\x00' + name_text = shstrtab.index(b'.text\x00') + name_shstr = shstrtab.index(b'.shstrtab\x00') + + ehsize = 64 if is64 else 52 + shentsize = 64 if is64 else 40 + n_sections = 3 # NULL, .text, .shstrtab + + text_off = ehsize + shstr_off = text_off + len(text_bytes) + shoff = shstr_off + len(shstrtab) + + # e_ident + ei_class = 2 if is64 else 1 + e_ident = b'\x7fELF' + bytes([ei_class, 1, 1, 0]) + b'\x00' * 8 + + if is64: + header = e_ident + struct.pack( + '. +''' + +import capstone +import pytest + +from rop3.arch import arch_singleton, ArchitectureSingleton +from rop3.archs.x86_arch import ( + X86_Architecture, X64_Architecture, REG_BY_WIDTH, +) + + +def test_singleton_initialize_and_matches(): + s = ArchitectureSingleton() + assert not s.is_initialized() + s.initialize(X64_Architecture()) + assert s.is_initialized() + assert s.matches(X64_Architecture()) + assert not s.matches(X86_Architecture()) + + +def test_singleton_initialize_is_sticky(): + ''' initialize() is a no-op once set (single-arch run guarantee). ''' + s = ArchitectureSingleton() + s.initialize(X64_Architecture()) + s.initialize(X86_Architecture()) + assert s.matches(X64_Architecture()) + + +def test_arch_accessed_before_init_raises(): + s = ArchitectureSingleton() + with pytest.raises(RuntimeError): + _ = s.arch + + +def test_arch_modes_and_pointers(): + assert X86_Architecture().mode == capstone.CS_MODE_32 + assert X64_Architecture().mode == capstone.CS_MODE_64 + assert (X86_Architecture().sp, X86_Architecture().bp) == ('esp', 'ebp') + assert (X64_Architecture().sp, X64_Architecture().bp) == ('rsp', 'rbp') + + +@pytest.mark.parametrize('alias,expected', [ + ('rax', 'eax'), ('eax', 'eax'), ('ax', 'eax'), ('al', 'eax'), + ('rbp', 'ebp'), ('r8', 'r8d'), ('r8d', 'r8d'), ('r14', 'r14d'), +]) +def test_normalize_reg_x86_is_32bit(alias, expected): + assert X86_Architecture().normalize_reg(alias) == expected + + +@pytest.mark.parametrize('alias,expected', [ + ('rax', 'rax'), ('eax', 'rax'), ('al', 'rax'), + ('rbp', 'rbp'), ('r8d', 'r8'), ('r14', 'r14'), +]) +def test_normalize_reg_x64_is_64bit(alias, expected): + assert X64_Architecture().normalize_reg(alias) == expected + + +def test_normalize_reg_passthrough_unknown(): + ''' Abstract / unknown register names are returned unchanged. ''' + assert X64_Architecture().normalize_reg('REG1') == 'REG1' + assert X86_Architecture().normalize_reg('dst') == 'dst' + + +def test_reg_by_width_table(): + assert REG_BY_WIDTH['rax'] == {8: 'rax', 4: 'eax'} + assert REG_BY_WIDTH['r8'] == {8: 'r8', 4: 'r8d'} + + +def test_is_valid_abstract_reg_width(): + assert X64_Architecture().is_valid_abstract_reg('rax') + 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') diff --git a/tests/test_elf.py b/tests/test_elf.py new file mode 100644 index 0000000..a42d26c --- /dev/null +++ b/tests/test_elf.py @@ -0,0 +1,73 @@ +''' +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 pytest + +import rop3.binaries.elf as elfmod +import rop3.binary as binary +from rop3.archs.x86_arch import X86_Architecture, X64_Architecture + +from conftest import build_minimal_elf, EM_386, EM_X86_64, ET_DYN + +TEXT = b'\x58\xc3' # pop rax ; ret + + +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) + + +def test_elf_detects_x86(): + data = build_minimal_elf(32, EM_386, TEXT, 0x8048000, ET_DYN) + assert isinstance(elfmod.ELF(data, None).get_arch(), X86_Architecture) + + +def test_elf_exec_section_extraction(): + data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN) + secs = elfmod.ELF(data, None).get_exec_sections() + assert len(secs) == 1 + assert secs[0]['vaddr'] == 0x1000 + assert secs[0]['opcodes'] == TEXT + + +def test_elf_base_relocation_pie(): + ''' Issue #13: --base must relocate ELF (ET_DYN image base is 0). ''' + data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN) + secs = elfmod.ELF(data, '0x400000').get_exec_sections() + assert secs[0]['vaddr'] == 0x401000 + + +def test_elf_no_base_keeps_addresses(): + data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN) + secs = elfmod.ELF(data, None).get_exec_sections() + assert secs[0]['vaddr'] == 0x1000 + + +def test_binary_dispatches_elf_by_magic(tmp_path): + data = build_minimal_elf(64, EM_X86_64, TEXT, 0x1000, ET_DYN) + path = tmp_path / 'fake.elf' + path.write_bytes(data) + b = binary.Binary(str(path), None) + assert isinstance(b.get_arch(), X64_Architecture) + assert b.get_exec_sections()[0]['opcodes'] == TEXT + + +def test_binary_unknown_format_raises(tmp_path): + path = tmp_path / 'junk.bin' + path.write_bytes(b'not a real binary header') + with pytest.raises(binary.BinaryException): + binary.Binary(str(path), None) diff --git a/tests/test_gadfinder.py b/tests/test_gadfinder.py new file mode 100644 index 0000000..cc2bea7 --- /dev/null +++ b/tests/test_gadfinder.py @@ -0,0 +1,97 @@ +''' +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 rop3.gadfinder as gadfinder +from rop3.archs.x86_arch import X86_Architecture + + +def test_avoid_bytes_defaults_to_canary(): + f = gadfinder.GadFinder() + assert f._avoid_bytes(None) == set(gadfinder.CANARY_BYTES) + assert f._avoid_bytes(['0x00', '0x0a', '0x0d', '0xff']) == {0x00, 0x0a, 0x0d, 0xff} + + +def test_avoid_bytes_uses_user_badchars(): + f = gadfinder.GadFinder() + assert f._avoid_bytes(['0x41', '0x42']) == {0x41, 0x42} + + +def test_addr_canary_score(x86): + from conftest import make_gadget + f = gadfinder.GadFinder() + avoid = set(gadfinder.CANARY_BYTES) + clean = make_gadget(b'\xc3', 0x12345620, mode=capstone.CS_MODE_32) + dirty = make_gadget(b'\xc3', 0x1234560a, mode=capstone.CS_MODE_32) # low byte 0x0a + assert f._addr_canary_score(clean, avoid) == 0 + assert f._addr_canary_score(dirty, avoid) == 1 + + +class _FakeBinary: + def __init__(self, vaddr, opcodes, filename='fake'): + self.filename = filename + self._vaddr = vaddr + self._opcodes = opcodes + + def get_exec_sections(self): + return [{'vaddr': self._vaddr, 'opcodes': self._opcodes}] + + def get_arch(self): + return X86_Architecture() + + +def _run_find(flags, buf, base_vaddr): + f = gadfinder.GadFinder(flags=flags) + f._open_binary = lambda fn, b: _FakeBinary(base_vaddr, bytes(buf)) + return f.find(['fake']) + + +def test_dedup_prefers_canary_free_address(x86): + ''' Issue #5: among duplicates keep the address with fewest canary bytes. ''' + base = 0x12345600 + buf = bytearray(0x40) + buf[0x0a] = 0xc3 # ret at ...0a (canary) + buf[0x20] = 0xc3 # ret at ...20 (clean) + + flags = gadfinder.ROP | gadfinder.AVOID_CANARY + rets = [g for g in _run_find(flags, buf, base) if g.text_repr == 'ret'] + assert len(rets) == 1 + assert rets[0].vaddr == 0x12345620 + assert rets[0].count == 2 + + +def test_dedup_keep_canary_keeps_first_seen(x86): + base = 0x12345600 + buf = bytearray(0x40) + buf[0x0a] = 0xc3 + buf[0x20] = 0xc3 + + rets = [g for g in _run_find(gadfinder.ROP, buf, base) if g.text_repr == 'ret'] + assert len(rets) == 1 + assert rets[0].vaddr == 0x1234560a # first seen, no canary preference + assert rets[0].count == 2 + + +def test_results_sorted_by_address(x86): + base = 0x10000 + buf = bytearray(0x40) + for off in (0x30, 0x05, 0x18): + buf[off] = 0xc3 + gadgets = _run_find(gadfinder.ROP, buf, base) + vaddrs = [g.vaddr for g in gadgets] + assert vaddrs == sorted(vaddrs) diff --git a/tests/test_gadget.py b/tests/test_gadget.py new file mode 100644 index 0000000..7a11ef3 --- /dev/null +++ b/tests/test_gadget.py @@ -0,0 +1,84 @@ +''' +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 rop3.gadget as gadget_mod +from rop3.gadget import heuristic_basic_count + +from conftest import make_gadget + + +def test_text_repr_and_equality(): + g1 = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret + g2 = make_gadget(b'\x58\xc3', 0x2000) # same text, different addr + g3 = make_gadget(b'\x5b\xc3', 0x1000) # pop rbx ; ret + assert g1.text_repr == 'pop rax ; ret' + assert g1 == g2 # equality is text-based + assert hash(g1) == hash(g2) + assert g1 != g3 + + +def test_calculate_side_effects_excludes_dst_src_sp(x64): + # inc rcx ; pop rbp ; ret + g = make_gadget(b'\x48\xff\xc1\x5d\xc3', 0x1000) + g.calculate_side_effects() + assert 'rcx' in g.side_regs + assert 'rbp' in g.side_regs + + +def test_calculate_side_effects_x86_uses_32bit_names(x86): + # inc ecx ; pop ebp ; ret + g = make_gadget(b'\x41\x5d\xc3', 0x1000, mode=capstone.CS_MODE_32) + g.calculate_side_effects() + assert 'ecx' in g.side_regs and 'ebp' in g.side_regs + # no 64-bit names leak into a 32-bit context + assert not ({'rcx', 'rbp'} & g.side_regs) + + +def test_subsumes(x64): + base = make_gadget(b'\x58\xc3', 0x1000) # pop rax ; ret + noisy = make_gadget(b'\x58\x5b\xc3', 0x2000) # pop rax ; pop rbx ; ret + base.side_regs = set() + noisy.side_regs = {'rbx'} + assert base.subsumes(noisy) + assert not noisy.subsumes(base) + + +def test_heuristic_basic_count(x64): + g = make_gadget(b'\x58\xc3', 0x1000) + g.side_regs = {'rbx'} + # 1 side reg (<<2 = 4) + 2 instructions (<<1 = 4) + assert heuristic_basic_count(g) == 8 + + +def test_str_no_color_when_not_tty(x64, monkeypatch, capsys): + ''' Regression for issue #14: no ANSI escapes on non-TTY output. ''' + monkeypatch.setattr('sys.stdout.isatty', lambda: False, raising=False) + g = make_gadget(b'\x48\xff\xc1\xc3', 0x1000) # inc rcx ; ret + g.calculate_side_effects() + text = str(g) + assert 'modifies' in text + assert '\033' not in text + + +def test_colorize_honors_tty_and_no_color(monkeypatch): + monkeypatch.setattr('sys.stdout.isatty', lambda: True, raising=False) + monkeypatch.delenv('NO_COLOR', raising=False) + assert '\033' in gadget_mod._colorize('x') + monkeypatch.setenv('NO_COLOR', '1') + assert '\033' not in gadget_mod._colorize('x') diff --git a/tests/test_operation.py b/tests/test_operation.py new file mode 100644 index 0000000..951d44e --- /dev/null +++ b/tests/test_operation.py @@ -0,0 +1,56 @@ +''' +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 rop3.operation as operation + +from conftest import make_gadget + + +def test_lc_matches_pop_reg(x64): + ''' lc (load constant) matches `pop ; ret`. ''' + gadgets = [ + make_gadget(b'\x58\xc3', 0x1000), # pop rax ; ret + 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) + texts = {g.text_repr for g in matched} + assert 'pop rax ; ret' in texts + assert 'pop rbx ; ret' in texts + assert 'nop ; ret' not in texts + + +def test_lc_with_dst_filter(x64): + gadgets = [ + 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) + assert [g.text_repr for g in matched] == ['pop rax ; ret'] + assert matched[0].dst == 'rax' + + +def test_filter_gadgets_empty_input(x64): + assert operation.Operation('lc').filter_gadgets([]) == [] + + +def test_operand_parse_imm_supports_hex_and_negative(x64): + ''' Regression: immediates parsed with int(x, 0). ''' + op = operation.Operand('rax') + assert op._parse_imm('0xffffffff') == 0xffffffff + assert op._parse_imm('-1') == -1 + assert op._parse_imm(42) == 42 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..0d22c9e --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,57 @@ +''' +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 pytest + +import rop3.parser as parser + + +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'): + assert expected in names + + +def test_get_op_unknown_raises(x64): + with pytest.raises(parser.ParserException): + parser.Parser().get_op('definitely_not_an_op') + + +def test_composite_op_is_recognised(x64): + ''' lsd is a composite (compose:) operation. ''' + resolved = parser.Parser().get_op('lsd') + assert isinstance(resolved, parser.CompositeOperation) + assert resolved.steps + + +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_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' + + +def test_reg_aliases_resolved_per_arch_x86(x86): + assert _jmp_mov_op1() == 'ebp' diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..49ae113 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,42 @@ +''' +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 + + +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 + + +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) == \ + 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. ''' + with pytest.raises(ValueError): + fn(0x1000, mode=999)