Skip to content

Lesson 8 of 9 · 22m

Patching: Changing Behavior Without Source

The constraint that shapes everything: you can't insert bytes, only overwrite them. Classic patches, code caves, and the integrity checks that punish careless edits.

Patching is where reverse engineering stops being read-only. The constraint that shapes everything: you cannot insert bytes — the binary is a fixed-size address space where every offset is load-bearing. You overwrite in place, or you build a trampoline out to space nobody is using.

The classic byte-level patches

; kill a check — overwrite the call with NOPs (0x90), matching length exactly
call check_license        ; E8 xx xx xx xx (5 bytes) -> 90 90 90 90 90

; invert a decision — flip the conditional jump opcode
jz   loc_401050           ; 74 xx -> 75 xx  (jz becomes jnz)
; or force it unconditionally:
je   loc_401050           ; 0F 84 .. .. .. .. -> E9 .. .. .. .. 90
                          ; (near jmp is one byte shorter — NOP the rest,
                          ;  and recompute rel32: it's relative to the next instruction)

; compare against a different constant
cmp  eax, 3               ; 83 F8 03 -> 83 F8 00

The beginner trap: instruction encodings have fixed lengths. Replacing a 2-byte jz with a 6-byte je overflows into the next instruction and corrupts the stream. Replace same-size-or-smaller and pad the remainder with 0x90.

When there's no room: code caves

Functions and sections end in alignment padding — int3/nop sleds nobody executes. That's a code cave: jmp there, run your extra instructions, jmp back. For anything larger, tools append a whole section and retarget the entry path — that's how real loaders and crack-style keygens inject actual logic rather than one-byte flips.

The check you didn't patch

Programs that care verify themselves: CRC regions over .text, Authenticode signatures, anti-tamper self-hashes. The classic failure — you patched the comparison perfectly and now the binary crashes on startup — means you changed bytes a self-checksum covers. Find the integrity check first (watchpoint the crash path, or look for code that reads its own .text), patch that, then patch the decision. Verify under the debugger: break on the patched block and watch it execute.

Doing it in the tools

In Ghidra: right-click an instruction → Patch Instruction (it assembles for you), then File → Export Program with format "Original File" to write a patched copy. In x64dbg: patch in place, Ctrl+P reviews every pending change, then write the file. Whatever the tool, keep a changelog — file offset: old bytes -> new bytes — because you will need to answer "what did I change" three sessions from now.

Field notes

  • Never delete bytes. Always NOP over them. Deleting shifts every subsequent address and silently breaks every absolute reference in the file.
  • Mind file offsets versus virtual addresses: the listing shows VAs; your hex editor wants file offsets. Check the offset column or compute through the section headers — editing at the VA directly is the second-most-common patch bug after rebasing.
  • Immediates are little-endian: cmp eax, 0x100 encodes as 3D 00 01 00 00. A byte editor carries none of the assembler's abstractions.
  • Your patch is versioned to this exact binary. The next auto-update lands and your one-byte fix is archaeology — keep the changelog so it's ten minutes, not a redo.