Return-Pointer Abuse
Uses push/ret and call-to-pop sequences as obfuscated control transfers so branches do not appear as calls or jumps, defeating call-graph recovery.
Disassemblers build the call graph and cross-references by tracking the targets
of call, jmp and jcc instructions. Return-pointer abuse breaks that
model by performing control transfers through the stack and the ret
instruction instead. A ret normally ends a function and returns to its caller;
when the malware pushes an arbitrary address and then executes ret, the
processor jumps to that address with no call or jmp in sight. The
disassembler sees a function epilogue, terminates the basic block, and records no
edge — so the destination shows up as never-called, unreferenced code.
The complementary trick is call-to-pop: a call whose target is the very
next instruction, where a pop immediately captures the pushed return address
into a register. The malware uses this to obtain its own runtime address (for
position-independent code and self-decryption) while simultaneously presenting a
call with no real callee body. Together these patterns turn linear control
flow into a chain of stack manipulations that automated call-graph recovery and
xref-based navigation cannot follow.
How it works
A push/ret pair replaces a direct jump and erases the cross-reference:
; "jmp loc_decrypt" rewritten as push/ret — no jmp/call xref is created
00: 68 EF BE AD DE push 0xDEADBEEF ; address of the real next stage
05: C3 ret ; "returns" to 0xDEADBEEF (a jump!)
; ; disassembler ends the block here,
; ; records NO edge to 0xDEADBEEFThe processor pops 0xDEADBEEF and transfers there, but the static tools treat
C3 as a return and stop. The target is reached at runtime yet appears
orphaned. A call/pop pair, by contrast, is used to read EIP/RIP and to
plant a fake return target:
; call-to-pop: get current address, no real subroutine exists
00: E8 00 00 00 00 call 0x05 ; target = next instruction
05: 58 pop eax ; eax = 0x05 (the return address on the stack)
06: ... ; eax now holds a runtime self-referenceThe call pushes the address of 0x05 and "calls" 0x05 itself; pop eax
takes that address off the stack. There is no callee — the disassembler invents a
phantom subroutine at 0x05, mangling the function layout. A more aggressive
form rewrites the return address on the stack before a legitimate-looking ret:
; redirect a normal-looking ret to an attacker-chosen target
00: 58 pop eax ; discard the real return address
01: 68 00 10 40 00 push 0x00401000 ; substitute a new "return" target
06: C3 ret ; transfers to 0x00401000, not the callerTo the call-graph builder this is an ordinary function return; the substituted destination is never linked as an edge, so the recovered graph is wrong.
Detection & analysis
Static analysis:
- Search for
push imm/push regimmediately followed byret— a near-certain obfuscated jump. IDA and Ghidra scripts (and patterns in capa) flagpush;retandcall $+5;popsequences specifically. - For
call/pop, look for acallwhose target is the instruction directly after it (a 5-byteE8 00 00 00 00) followed by apop— the get-EIP idiom. - After identifying the pushed constant, manually add a cross-reference / create a code chunk at that address so the call graph reconnects.
Dynamic analysis:
- Single-step over the
retin a debugger and read the new EIP/RIP — it reveals the true (otherwise unreferenced) destination. Set a hardware breakpoint there to continue tracing. - A branch trace (Intel PT, last-branch records, or an emulator) records the
real transfer at the
ret/call, reconstructing the edges the static graph is missing.
Detection rule hint:
Flag any ret that is immediately preceded by a push imm32/push reg with no
intervening call frame, and any 5-byte call $+5 (E8 00 00 00 00) followed
by a pop. These stack-based control transfers create execution edges that no
call/jmp instruction expresses, and their presence is a strong indicator of
deliberate call-graph obfuscation rather than normal subroutine returns.