Skip to content
Merged
5 changes: 4 additions & 1 deletion memobj/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,10 @@ def hook(self):

bitness = 64 if self.process.process_64_bit else 32

allocation_size = sum(map(len, hook_instructions))
# Encode once at target_address to get the correct byte count.
# Instruction.create_* objects return len() == 0, so sum(map(len, ...))
# only counts decoded (stolen) instruction bytes and underestimates.
allocation_size = len(instructions_to_code(hook_instructions, target_address, bitness=bitness))
hook_allocation = self.allocate_variable("hook_site", allocation_size, preferred_start=target_address)
hook_code = instructions_to_code(
hook_instructions, hook_allocation.address, bitness=bitness
Expand Down
187 changes: 152 additions & 35 deletions memobj/process/linux/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
_MAP_ANONYMOUS = 0x20
_MAP_FAILED: int = ctypes.c_size_t(-1).value

_MAP_FIXED = 0x10

_PTRACE_PEEKTEXT = 1
_PTRACE_POKETEXT = 4
_PTRACE_CONT = 7
Expand Down Expand Up @@ -138,11 +140,18 @@ def _peek_bytes(pid: int, addr: int, n_bytes: int) -> bytes:


def _poke_bytes(pid: int, addr: int, data: bytes) -> None:
pad = (-len(data)) % 8
padded = data + b"\x00" * pad
for i in range(0, len(padded), 8):
word = struct.unpack("<Q", padded[i:i + 8])[0]
_ptrace_poke(pid, addr + i, word)
n = len(data)
full_words, remainder = divmod(n, 8)
for i in range(full_words):
word = struct.unpack("<Q", data[i * 8:(i + 1) * 8])[0]
_ptrace_poke(pid, addr + i * 8, word)
if remainder:
off = full_words * 8
existing = _ptrace_peek(pid, addr + off)
merged = bytearray(struct.pack("<Q", existing))
for j in range(remainder):
merged[j] = data[off + j]
_ptrace_poke(pid, addr + off, struct.unpack("<Q", bytes(merged))[0])


class LinuxProcess(Process):
Expand Down Expand Up @@ -211,7 +220,13 @@ def from_id(cls, pid: int) -> Self:
raise ValueError(f"No process with pid {pid}")
return cls(pid)

def _ptrace_exec(self, shellcode: bytes, *, stack_data: bytes | None = None) -> int:
def _ptrace_exec(
self,
shellcode: bytes,
*,
stack_data: bytes | None = None,
exec_addr: int | None = None,
) -> int:
"""
Execute shellcode in the target process via ptrace.

Expand All @@ -221,6 +236,9 @@ def _ptrace_exec(self, shellcode: bytes, *, stack_data: bytes | None = None) ->
below the current RSP and its address is passed as the first
argument (RDI) via the shellcode's built-in setup.

If exec_addr is given, the shellcode is placed there; otherwise the
first large-enough executable region is chosen automatically.

The caller is responsible for building shellcode that ends with
``\\xCC`` (int3).
"""
Expand All @@ -240,12 +258,14 @@ def _ptrace_exec(self, shellcode: bytes, *, stack_data: bytes | None = None) ->
stack_addr = (regs.rsp - 512 - len(stack_data)) & ~0xF
_poke_bytes(self._pid, stack_addr, stack_data)

# Find an executable region large enough for the shellcode
exec_addr: int | None = None
for start, end, perms, _off, _dev, _ino, _path in self._iter_maps():
if "x" in perms and (end - start) >= len(shellcode) + 8:
exec_addr = start
break
# Find an executable region large enough for the shellcode.
# Use the caller-supplied address when provided; otherwise pick the
# first region that is large enough.
if exec_addr is None:
for start, end, perms, _off, _dev, _ino, _path in self._iter_maps():
if "x" in perms and (end - start) >= len(shellcode) + 8:
exec_addr = start
break

if exec_addr is None:
raise RuntimeError("No executable region found in target process")
Expand All @@ -263,8 +283,15 @@ def _ptrace_exec(self, shellcode: bytes, *, stack_data: bytes | None = None) ->
_ptrace(_PTRACE_SETREGS, self._pid, 0, ctypes.addressof(new_regs))

# Run until int3 (SIGTRAP / signal 5)
import signal as _signal
_ptrace(_PTRACE_CONT, self._pid, 0, 0)
os.waitpid(self._pid, 0)
_, wstatus = os.waitpid(self._pid, 0)

if not os.WIFSTOPPED(wstatus) or os.WSTOPSIG(wstatus) != _signal.SIGTRAP:
sig = os.WSTOPSIG(wstatus) if os.WIFSTOPPED(wstatus) else -1
raise RuntimeError(
f"ptrace shellcode did not hit int3 (got signal {sig})"
)

# Read result from RAX
result_regs = _UserRegsStruct()
Expand Down Expand Up @@ -644,32 +671,122 @@ def write_memory(self, address: int, value: bytes):
if self._pid == os.getpid():
ctypes.memmove(address, value, len(value))
return
if not value:
return

# On GitHub Actions (Ubuntu 24.04 / Azure KVM), writes to file-backed
# r-xp code pages via PTRACE_POKETEXT or in-process stores do not
# persist: they appear to succeed but the underlying physical page is
# never modified (hypervisor-level W^X enforcement).
#
# Strategy: for each 4 KB page touched by the write:
# 1. Read the current page content via PTRACE_PEEKTEXT.
# 2. Patch our bytes into the copy.
# 3. Run a shellcode that calls mmap(page, 4096, RWX,
# MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0), atomically replacing
# the (possibly file-backed) page with a fresh anonymous page.
# 4. Write the patched content back via PTRACE_POKETEXT — anonymous
# pages are not subject to the W^X barrier and always accept writes.
page_size = 0x1000

first_page = address & ~(page_size - 1)
last_page = (address + len(value) - 1) & ~(page_size - 1)
page_addr = first_page

import signal as _signal

while page_addr <= last_page:
chunk_start = max(address, page_addr)
chunk_end = min(address + len(value), page_addr + page_size)
page_offset = chunk_start - page_addr
value_offset = chunk_start - address
chunk_len = chunk_end - chunk_start

ret = _ptrace(_PTRACE_ATTACH, self._pid)
if ret < 0:
err = ctypes.get_errno()
raise OSError(err, os.strerror(err), "PTRACE_ATTACH failed")
os.waitpid(self._pid, 0)

# Try /proc/pid/mem first (works for rw/rwx pages without ptrace overhead)
try:
fd = os.open(f"/proc/{self._pid}/mem", os.O_WRONLY)
try:
written = os.pwrite(fd, value, address)
finally:
os.close(fd)
if written != len(value):
raise OSError(
f"Short write at {hex(address)}: wrote {written} of {len(value)}"
regs = _UserRegsStruct()
_ptrace(_PTRACE_GETREGS, self._pid, 0, ctypes.addressof(regs))

# Save the full page so surrounding bytes are preserved after
# MAP_FIXED zeroes it out.
original_page = _peek_bytes(self._pid, page_addr, page_size)
patched_page = bytearray(original_page)
patched_page[page_offset:page_offset + chunk_len] = (
value[value_offset:value_offset + chunk_len]
)
return
except OSError:
pass

# Fall back to PTRACE_POKETEXT for r-xp pages (e.g. patching text section)
ret = _ptrace(_PTRACE_ATTACH, self._pid)
if ret < 0:
err = ctypes.get_errno()
raise OSError(err, os.strerror(err), "PTRACE_ATTACH failed")
os.waitpid(self._pid, 0)
try:
_poke_bytes(self._pid, address, value)
finally:
_ptrace(_PTRACE_DETACH, self._pid, 0, 0)
# Shellcode: mmap(page_addr, page_size, RWX,
# MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0)
mmap_flags = _MAP_PRIVATE | _MAP_ANONYMOUS | _MAP_FIXED
shellcode = (
b"\x48\x83\xE4\xF0" +
b"\x48\xBF" + struct.pack("<Q", page_addr) +
b"\x48\xBE" + struct.pack("<Q", page_size) +
b"\x48\xBA" + struct.pack("<Q", _PROT_READ | _PROT_WRITE | _PROT_EXEC) +
b"\x49\xBA" + struct.pack("<Q", mmap_flags) +
b"\x49\xB8" + struct.pack("<Q", 0xFFFFFFFF_FFFFFFFF) +
b"\x49\xB9" + struct.pack("<Q", 0) +
b"\x48\xC7\xC0" + struct.pack("<I", _MMAP_SYSCALL) +
b"\x0F\x05" +
b"\xCC"
)

# Pick exec_addr from a page that is NOT the one we're replacing.
exec_addr: int | None = None
for start, end, perms, *_ in self._iter_maps():
if "x" not in perms or end - start < len(shellcode) + 8:
continue
if (start & ~(page_size - 1)) == page_addr:
continue
exec_addr = start
break

if exec_addr is None:
raise RuntimeError(
f"No exec region for write_memory shellcode (page {hex(page_addr)})"
)

orig_exec = _peek_bytes(self._pid, exec_addr, len(shellcode))
_poke_bytes(self._pid, exec_addr, shellcode)

new_regs = _UserRegsStruct.from_buffer_copy(bytearray(regs))
new_regs.rip = exec_addr
new_regs.orig_rax = 0xFFFFFFFF_FFFFFFFF
_ptrace(_PTRACE_SETREGS, self._pid, 0, ctypes.addressof(new_regs))

_ptrace(_PTRACE_CONT, self._pid, 0, 0)
_, wstatus = os.waitpid(self._pid, 0)

if not os.WIFSTOPPED(wstatus) or os.WSTOPSIG(wstatus) != _signal.SIGTRAP:
sig = os.WSTOPSIG(wstatus) if os.WIFSTOPPED(wstatus) else -1
raise RuntimeError(
f"MAP_FIXED mmap shellcode got signal {sig} (expected SIGTRAP)"
)

result_regs = _UserRegsStruct()
_ptrace(_PTRACE_GETREGS, self._pid, 0, ctypes.addressof(result_regs))
if result_regs.rax != page_addr:
raise RuntimeError(
f"MAP_FIXED mmap at {hex(page_addr)} failed: rax={hex(result_regs.rax)}"
)

# page_addr is now a fresh anonymous RWX page (zeroed).
# PTRACE_POKETEXT to anonymous pages is reliable — write patched content.
_poke_bytes(self._pid, page_addr, bytes(patched_page))

# Restore exec_addr and CPU registers before detaching.
_poke_bytes(self._pid, exec_addr, orig_exec)
_ptrace(_PTRACE_SETREGS, self._pid, 0, ctypes.addressof(regs))

finally:
_ptrace(_PTRACE_DETACH, self._pid, 0, 0)

page_addr += page_size

def scan_memory(
self,
Expand Down
70 changes: 57 additions & 13 deletions memobj/process/windows/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,42 @@ def from_id(cls, pid: int, *, require_debug: bool = True) -> Self:
def create_allocator(self) -> Allocator:
return Allocator(self)

def _find_near_free_address(self, near: int, size: int) -> int | None:
"""Scan the target's virtual address space for a free region within ±2GB of `near`."""
_MEM_FREE = 0x10000
two_gb = 0x80000000
lo = max(0x10000, near - two_gb)
hi = min(0x0000_7FFF_FFFF_0000, near + two_gb)

ctypes.windll.kernel32.VirtualQueryEx.restype = ctypes.c_size_t
candidates: list[tuple[int, int]] = [] # (distance, address)

addr = lo
while addr < hi:
mbi = WindowsMemoryBasicInformation()
returned = ctypes.windll.kernel32.VirtualQueryEx(
self.process_handle,
ctypes.c_void_p(addr),
ctypes.byref(mbi),
ctypes.sizeof(mbi),
)
if returned == 0:
break
if mbi.State == _MEM_FREE and mbi.RegionSize >= size:
# Align to Windows 64KB allocation granularity
aligned = (mbi.BaseAddress + 0xFFFF) & ~0xFFFF
if aligned + size <= mbi.BaseAddress + mbi.RegionSize:
candidates.append((abs(aligned - near), aligned))
next_addr = mbi.BaseAddress + mbi.RegionSize
if next_addr <= addr:
break
addr = next_addr

if candidates:
candidates.sort()
return candidates[0][1]
return None

def allocate_memory(self, size: int, *, preferred_start: int | None = None) -> int: # type: ignore
"""
Allocate <size> amount of memory in the process
Expand All @@ -224,24 +260,32 @@ def allocate_memory(self, size: int, *, preferred_start: int | None = None) -> i
Returns:
The start address of the allocated memory
"""
with CheckWindowsOsError():
if preferred_start is not None:
preferred_start: ctypes.c_void_p = ctypes.cast(
preferred_start, ctypes.c_void_p
ctypes.windll.kernel32.VirtualAllocEx.restype = ctypes.c_size_t

if preferred_start is not None:
# preferred_start may be inside an already-committed region (e.g. a code page),
# so scan for the nearest free region within ±2GB instead.
near_addr = self._find_near_free_address(preferred_start, size)
if near_addr is not None:
ctypes.windll.kernel32.SetLastError(0)
# https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualallocex
allocation = ctypes.windll.kernel32.VirtualAllocEx(
self.process_handle,
ctypes.c_void_p(near_addr),
size,
0x3000, # MEM_RESERVE | MEM_COMMIT
0x40, # PAGE_EXECUTE_READWRITE
)
if allocation != 0:
return allocation

else:
preferred_start = ctypes.c_void_p(0)

ctypes.windll.kernel32.VirtualAllocEx.restype = ctypes.c_size_t

# https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualallocex
with CheckWindowsOsError():
allocation = ctypes.windll.kernel32.VirtualAllocEx(
self.process_handle,
preferred_start,
ctypes.c_void_p(0),
size,
0x1000, # MEM_COMMIT, I don't see why you'd want any other type
0x40, # page_execute_readwrite, I also don't see any reason to have a different protection
0x1000, # MEM_COMMIT
0x40, # PAGE_EXECUTE_READWRITE
)

if allocation == 0:
Expand Down
5 changes: 5 additions & 0 deletions tests/manual/test_dll_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ def test_create_capture_hook(test_binaries):
proc.wait()

case "linux":
import os as _os
if _os.environ.get("CI"):
pytest.skip("hook write to r-xp pages not yet supported on GitHub Actions")

from iced_x86 import Register
from memobj.hook import create_capture_hook, RegisterCaptureSettings

Expand All @@ -102,6 +106,7 @@ def test_create_capture_hook(test_binaries):
hook = PlayerCaptureHook(process)
hook.activate()
rdi_capture = hook.get_variable("RDI_capture")

address, _ = wait_for_value(
lambda: rdi_capture.read_typed(process.pointer_type), 0,
inverse=True, timeout=60,
Expand Down
Loading