Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
pythonpath = .
addopts = -ra
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest >= 7.0
146 changes: 146 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
'''

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(
'<HHIQQQIHHHHHH',
e_type, machine, 1, # type, machine, version
text_addr, # entry
0, # phoff
shoff, # shoff
0, # flags
ehsize, 0, 0, # ehsize, phentsize, phnum
shentsize, n_sections, 2, # shentsize, shnum, shstrndx
)
else:
header = e_ident + struct.pack(
'<HHIIIIIHHHHHH',
e_type, machine, 1,
text_addr,
0,
shoff,
0,
ehsize, 0, 0,
shentsize, n_sections, 2,
)

def section64(name, stype, flags, addr, offset, size):
return struct.pack('<IIQQQQIIQQ', name, stype, flags, addr,
offset, size, 0, 0, 1, 0)

def section32(name, stype, flags, addr, offset, size):
return struct.pack('<IIIIIIIIII', name, stype, flags, addr,
offset, size, 0, 0, 1, 0)

section = section64 if is64 else section32

sections = b''.join([
section(0, 0, 0, 0, 0, 0), # NULL
section(name_text, SHT_PROGBITS, SHF_ALLOC | SHF_EXECINSTR,
text_addr, text_off, len(text_bytes)), # .text
section(name_shstr, SHT_STRTAB, 0, 0, shstr_off, len(shstrtab)), # .shstrtab
])

return header + text_bytes + shstrtab + sections
88 changes: 88 additions & 0 deletions tests/test_arch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'''
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 <https://www.gnu.org/licenses/>.
'''

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')
73 changes: 73 additions & 0 deletions tests/test_elf.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
'''

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)
Loading
Loading