Overview
Binary patching with angr enables:- Security patch injection without source code
- Vulnerability mitigation
- Performance optimization
- Functionality modification
- Instrumentation and hooking
- Binary hardening
Reassembler Basics
The Reassembler analysis converts binaries to assembly, allowing modification and reassembly.Basic Reassembly Workflow
Load and analyze the binary
import angr
# Load the target binary
project = angr.Project("./target_binary", auto_load_libs=False)
# Generate CFG for reassembler
cfg = project.analyses.CFGFast()
Create reassembler instance
# Initialize the reassembler
reassembler = project.analyses.Reassembler(
cfg=cfg,
syntax='intel' # or 'at&t'
)
Get assembly output
# Get the reassembled code
assembly_code = reassembler.assembly
# Save to file
with open('output.s', 'w') as f:
f.write(assembly_code)
Patching Techniques
Removing Vulnerable Instructions
Remove dangerous function calls or instructions:import angr
project = angr.Project("./vulnerable_binary", auto_load_libs=False)
cfg = project.analyses.CFGFast()
reassembler = project.analyses.Reassembler(cfg=cfg)
# Find and remove strcpy calls
strcpy_addr = 0x401234 # Address of strcpy call
# Mark instruction for removal
reassembler.remove_instruction(strcpy_addr)
# Generate modified assembly
modified_asm = reassembler.assembly
with open('patched.s', 'w') as f:
f.write(modified_asm)
Removing instructions can break the binary. Always insert equivalent safe code or adjust control flow.
Inserting Security Checks
Add bounds checking before dangerous operations:import angr
project = angr.Project("./binary", auto_load_libs=False)
cfg = project.analyses.CFGFast()
reassembler = project.analyses.Reassembler(cfg=cfg)
# Insert bounds check before buffer copy
buffer_copy_addr = 0x401234
# Assembly code for bounds check (Intel syntax example)
bounds_check = """
# Save registers
push rax
push rcx
# Check if size > MAX_SIZE
mov rax, rsi # size argument
cmp rax, 256 # MAX_SIZE = 256
jle .safe_copy
# Size too large - abort
mov rax, 60 # sys_exit
mov rdi, 1 # exit code
syscall
.safe_copy:
# Restore registers
pop rcx
pop rax
"""
# Insert the check before the dangerous instruction
reassembler.insert_asm_before_label(buffer_copy_addr, bounds_check)
# Generate patched binary
patched_asm = reassembler.assembly
with open('hardened.s', 'w') as f:
f.write(patched_asm)
Replacing Function Calls
Replace dangerous functions with safer alternatives:import angr
class FunctionReplacer:
def __init__(self, project, cfg):
self.project = project
self.cfg = cfg
self.reassembler = project.analyses.Reassembler(cfg=cfg)
def replace_function_calls(self, old_func, new_func):
"""
Replace all calls to old_func with calls to new_func
Args:
old_func: Address or name of function to replace
new_func: Address or name of replacement function
"""
# Find all call sites
call_sites = self._find_call_sites(old_func)
for call_addr in call_sites:
# Get the replacement assembly
replacement = f"call {new_func}"
# Remove old call
self.reassembler.remove_instruction(call_addr)
# Insert new call
self.reassembler.insert_asm_after_label(
call_addr,
replacement
)
return self.reassembler.assembly
def _find_call_sites(self, func_name):
"""Find all addresses that call the given function"""
call_sites = []
for func in self.cfg.kb.functions.values():
for block in func.blocks:
# Get capstone disassembly
cs_block = self.project.factory.block(block.addr).capstone
for insn in cs_block.insns:
if insn.mnemonic == 'call':
# Check if it's calling our target
if func_name in insn.op_str:
call_sites.append(insn.address)
return call_sites
# Usage
project = angr.Project("./binary", auto_load_libs=False)
cfg = project.analyses.CFGFast()
replacer = FunctionReplacer(project, cfg)
# Replace strcpy with strncpy
patched = replacer.replace_function_calls('strcpy', 'strncpy')
with open('safer_binary.s', 'w') as f:
f.write(patched)
Binary Optimizer
The BinaryOptimizer analysis applies code optimizations to improve performance.Available Optimizations
- Constant Propagation
- Dead Assignment Elimination
- Register Reallocation
- Redundant Stack Variable Removal
Replaces variables with their constant values:Example:
import angr
project = angr.Project("./unoptimized", auto_load_libs=False)
cfg = project.analyses.CFGFast()
# Run constant propagation
optimizer = project.analyses.BinaryOptimizer(
cfg=cfg,
techniques={'constant_propagation'}
)
# View optimizations found
for cp in optimizer.constant_propagations:
print(f"Constant {hex(cp.constant)} propagates from " +
f"{hex(cp.constant_assignment_loc.ins_addr)} to " +
f"{hex(cp.constant_consuming_loc.ins_addr)}")
# Before
mov eax, 42
mov [ebp-4], eax
mov ebx, [ebp-4]
# After
mov ebx, 42
Removes assignments to unused variables:Example:
optimizer = project.analyses.BinaryOptimizer(
cfg=cfg,
techniques={'dead_assignment_elimination'}
)
for dead in optimizer.dead_assignments:
print(f"Dead assignment: {dead.pv}")
# Before
mov eax, 123 # Dead - eax never used
mov ebx, 456
call foo
# After
mov ebx, 456
call foo
Moves stack variables to registers:Example:
optimizer = project.analyses.BinaryOptimizer(
cfg=cfg,
techniques={'register_reallocation'}
)
for rr in optimizer.register_reallocations:
print(f"{rr.register_variable} will replace {rr.stack_variable}")
print(f" Sources: {len(rr.stack_variable_sources)}")
print(f" Consumers: {len(rr.stack_variable_consumers)}")
# Before
mov [ebp-4], eax # Store to stack
...
mov ebx, [ebp-4] # Load from stack
# After (using free register esi)
mov esi, eax # Use register
...
mov ebx, esi # Direct register access
Eliminates unnecessary stack copies:Example:
optimizer = project.analyses.BinaryOptimizer(
cfg=cfg,
techniques={'redundant_stack_variable_removal'}
)
for rsv in optimizer.redundant_stack_variables:
print(f"Redundant: {rsv.stack_variable} for {rsv.argument}")
print(f" Used at {len(rsv.stack_variable_consuming_locs)} locations")
# Before
# Function copies argument to local variable
mov eax, [ebp+8] # Load argument
mov [ebp-4], eax # Save to local
...
mov ebx, [ebp-4] # Use local copy
# After
# Direct use of argument
mov ebx, [ebp+8] # Use argument directly
Full Optimization Pipeline
import angr
class BinaryPatcher:
"""Complete binary optimization and patching"""
def __init__(self, binary_path):
self.project = angr.Project(binary_path, auto_load_libs=False)
self.cfg = None
self.optimizer = None
self.reassembler = None
def analyze(self):
"""Build CFG"""
print("[*] Building CFG...")
self.cfg = self.project.analyses.CFGFast()
print(f"[+] Found {len(self.cfg.kb.functions)} functions")
def optimize(self, techniques=None):
"""Apply optimizations"""
if techniques is None:
techniques = {
'constant_propagation',
'register_reallocation',
'redundant_stack_variable_removal',
}
print(f"[*] Running optimizations: {techniques}")
self.optimizer = self.project.analyses.BinaryOptimizer(
cfg=self.cfg,
techniques=techniques
)
# Report results
if 'constant_propagation' in techniques:
print(f"[+] Constant propagations: {len(self.optimizer.constant_propagations)}")
if 'register_reallocation' in techniques:
print(f"[+] Register reallocations: {len(self.optimizer.register_reallocations)}")
if 'redundant_stack_variable_removal' in techniques:
print(f"[+] Redundant stack variables: {len(self.optimizer.redundant_stack_variables)}")
def patch_vulnerabilities(self, vuln_addrs):
"""Patch specific vulnerability addresses"""
print(f"[*] Patching {len(vuln_addrs)} vulnerabilities...")
self.reassembler = self.project.analyses.Reassembler(
cfg=self.cfg,
syntax='intel'
)
for addr, patch_type in vuln_addrs:
if patch_type == 'remove':
self.reassembler.remove_instruction(addr)
print(f"[+] Removed instruction at {hex(addr)}")
elif patch_type == 'bounds_check':
# Insert bounds check
check_code = self._generate_bounds_check()
self.reassembler.insert_asm_before_label(addr, check_code)
print(f"[+] Added bounds check at {hex(addr)}")
def _generate_bounds_check(self):
"""Generate bounds checking code"""
return """
push rax
cmp rsi, 256
jle .continue
mov rax, 60
mov rdi, 1
syscall
.continue:
pop rax
"""
def generate_output(self, output_path):
"""Generate patched assembly"""
print(f"[*] Generating assembly to {output_path}...")
if self.reassembler is None:
self.reassembler = self.project.analyses.Reassembler(
cfg=self.cfg,
syntax='intel'
)
assembly = self.reassembler.assembly
with open(output_path, 'w') as f:
f.write(assembly)
print(f"[+] Assembly written to {output_path}")
print("[*] To compile:")
print(f" as -o patched.o {output_path}")
print(f" ld -o patched patched.o")
# Usage
patcher = BinaryPatcher("./vulnerable_binary")
patcher.analyze()
patcher.optimize()
# Patch specific vulnerabilities
vulnerabilities = [
(0x401234, 'bounds_check'), # strcpy with bounds check
(0x401567, 'remove'), # Remove dangerous instruction
]
patcher.patch_vulnerabilities(vulnerabilities)
patcher.generate_output("patched.s")
Advanced Patching Examples
Adding Stack Canary Protection
import angr
def add_stack_canary(project, function_addr):
"""Add stack canary to a function"""
cfg = project.analyses.CFGFast()
reassembler = project.analyses.Reassembler(cfg=cfg, syntax='intel')
func = cfg.kb.functions.get(function_addr)
if not func:
return None
# Prologue: save canary
prologue_code = """
# Load canary from fs:0x28 (x86-64 Linux)
mov rax, QWORD PTR fs:0x28
mov QWORD PTR [rbp-8], rax
xor rax, rax
"""
# Insert after function prologue
reassembler.insert_asm_after_label(function_addr, prologue_code)
# Epilogue: check canary before each return
for block in func.blocks:
cs_block = project.factory.block(block.addr).capstone
for insn in cs_block.insns:
if insn.mnemonic == 'ret':
epilogue_code = """
mov rax, QWORD PTR [rbp-8]
xor rax, QWORD PTR fs:0x28
je .canary_ok
call __stack_chk_fail
.canary_ok:
"""
reassembler.insert_asm_before_label(insn.address, epilogue_code)
return reassembler.assembly
# Usage
project = angr.Project("./binary", auto_load_libs=False)
patched = add_stack_canary(project, 0x401000)
if patched:
with open('canary_protected.s', 'w') as f:
f.write(patched)
Instrumenting for Logging/Tracing
import angr
def add_tracing(project, function_addr):
"""Add entry/exit tracing to a function"""
cfg = project.analyses.CFGFast()
reassembler = project.analyses.Reassembler(cfg=cfg)
func = cfg.kb.functions.get(function_addr)
func_name = func.name if func else f"func_{hex(function_addr)}"
# Entry tracing
entry_trace = f"""
push rdi
push rsi
lea rdi, [rip+.entry_msg]
call puts
pop rsi
pop rdi
jmp .continue_entry
.entry_msg:
.asciz "TRACE: Entering {func_name}"
.continue_entry:
"""
reassembler.insert_asm_after_label(function_addr, entry_trace)
# Exit tracing for each return
for block in func.blocks:
cs_block = project.factory.block(block.addr).capstone
for insn in cs_block.insns:
if insn.mnemonic == 'ret':
exit_trace = f"""
push rdi
lea rdi, [rip+.exit_msg]
call puts
pop rdi
jmp .continue_exit
.exit_msg:
.asciz "TRACE: Exiting {func_name}"
.continue_exit:
"""
reassembler.insert_asm_before_label(insn.address, exit_trace)
return reassembler.assembly
Working with Different Architectures
project = angr.Project("./binary_x64", auto_load_libs=False)
reassembler = project.analyses.Reassembler(
cfg=cfg,
syntax='intel' # or 'at&t'
)
Reassembler works best with x86/x86-64. ARM and other architectures have limited support and may require manual assembly modification.
Best Practices
Validate patched binary
# Test basic functionality
import subprocess
result = subprocess.run(
['./patched_binary', 'test'],
capture_output=True,
timeout=5
)
assert result.returncode == 0, "Patched binary crashed"
Preserve original semantics
Only modify what’s necessary. Overly aggressive patching can break functionality.