Skip to content

Name local plt stubs in x86 retpoline layouts ##analysis - #26469

Merged
trufae merged 1 commit into
radareorg:masterfrom
phix33:anal-plt-retpoline
Aug 15, 2026
Merged

Name local plt stubs in x86 retpoline layouts ##analysis#26469
trufae merged 1 commit into
radareorg:masterfrom
phix33:anal-plt-retpoline

Conversation

@phix33

@phix33 phix33 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator
  • Mark this if you consider it ready to merge
  • I've added tests (optional)
  • I wrote some lines in the book (optional)

Description

Step 5 of the plan, done the way you recommended — a small extension to #26457's scanner rather than the elf.c byte matcher.

lld's retpoline PLT loads the GOT slot into r11 and then reaches the shared thunk with a direct branch instead of jumping through the slot, so the scan never saw an indirect terminator. An unconditional call/jmp whose target stays inside the section being scanned now yields a slot candidate too — but only when one is actually complete: a dereferencing load gives the slot outright, a bare lea/adrp gives a base and is only usable once a displacement has been seen.

; -z retpolineplt (lazy)          ; -z retpolineplt -z now
14b0: mov r11, [rip+0x2111]       1460: mov r11, [rip+0x1101]
14b7: call 0x14a0                 1467: jmp 0x1440

One difference from your sketch: it named only the call-terminated form, but -z now ends in a direct jmp, so accepting only calls leaves that half at zero. Both are handled.

No testbins dependency after all — elf/pltrel/x86_64-retpoline.so and -now.so are already merged, so the five tests land here. caller in both goes from call fcn.000014b0 to call sym.plt.add2.

Section-less retpoline stays at zero, since sections are picked by name — same as every other arch here.

@trufae

trufae commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

The bug: slot = lea_ptr + ((load_disp == UT64_MAX)? 0: load_disp) treats "lea seen, no load" as a complete slot. That's correct for x86 (mov r11, [rip+disp] yields the absolute slot address in one op) but wrong for the split lea/load shape: an adrp/lea sets lea_ptr to a page/base address, and if a direct in-section branch follows before any load, you flag lea_ptr+0 — a page base, not a slot. If that base happens to collide with a reloc address, you get a misnamed stub. The reloc lookup in plt_stub_flag filters most misfires, which is why the tests can't catch it.

Fix — make slot completeness explicit instead of defaulting the displacement to 0:

case R_ANAL_OP_TYPE_JMP:
case R_ANAL_OP_TYPE_CALL:
	// retpoline stubs branch to an in-section thunk
	if (op.jump >= sec_vaddr && op.jump < sec_end) {
		if (lea_ptr != UT64_MAX && load_disp != UT64_MAX) {
			slot = lea_ptr + load_disp; // split lea+load shape
		} else if (lea_is_load) {
			slot = lea_ptr; // x86: mov reg, [rip+disp] is the full slot
		}
	}
	break;

with a bool lea_is_load = false; alongside the other trackers, set true in the case that records the rip-relative mov (that case is outside the visible hunks — wherever lea_ptr gets set from a load-type op rather than LEA/ADRP), false in the pure LEA case, and reset wherever lea_ptr/load_disp reset.

Other problems, in descending order of relevance:

  1. Stale-state re-flagging is doing load-bearing work in the test. In the lazy stub (mov; call thunk; jmp header), the call flags the entry at size 12, then the trailing jmp — same stale lea_ptr — re-flags it at size 17. The expected 0x000014b0 17 in the test only holds because of this accidental double-flag. Benign today (same slot, same name), but it means any future reset of lea_ptr after flagging silently changes the flag size and breaks the test. Decide intentionally: either reset state + advance entry after a direct-branch flag (then the lazy EXPECT becomes 12, symmetric with -z now), or keep it and add a comment saying the trailing lazy jmp intentionally extends the entry. Don't leave it implicit.

  2. op.jump edge at sec_vaddr == 0. The in-section check op.jump >= sec_vaddr degenerates when the section starts at 0: an unset/zero op.jump from a failed decode passes. Rare given sections are picked by name, but a op.jump != UT64_MAX guard costs nothing. Note a branch to exactly sec_vaddr is legitimate (lazy path targets the header), so don't exclude that.

  3. Pre-existing, not this PR: (op.ptr > 0 && op.ptr != -1) — the second condition is dead after the first. Cosmetic.

Caveat: the diff hunks cut off the tracking cases (LEA/MOV recording and the reset points), so I can't see exactly which op type sets lea_ptr for the x86 mov — the fix above names where the new flag has to go, but verify the placement against the full function from #26457 before applying.

@phix33
phix33 force-pushed the anal-plt-retpoline branch from 813104f to c694154 Compare August 15, 2026 10:18
@phix33
phix33 force-pushed the anal-plt-retpoline branch from c694154 to 996228b Compare August 15, 2026 11:05
@phix33

phix33 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

All four addressed; head 996228b, gate clean.

Fixed slot bug. Instead of a hand-set flag I took op.direction, which the decoder already fills: mov r11, [rip+disp] is read (the ptr is the slot), lea/adrp are ref (a base). So lea_ptr + load_disp when a load was seen, bare lea_ptr only when it came from a read, otherwise decline.

Not hypothetical — on a copy of aarch64-bti-lld.so, moving add2's .rela.plt r_offset to the page base 0x3000 and turning mul2's ldr into b .plt (r2 -n -w -c 'wx 0030000000000000 @ 0x380; wx f1ffff17 @ 0x50c') flags the mul2 stub as sym.plt.add2 before the fix, and nothing after. You were right that it can't be asserted in-tree: reloc parsing doesn't read through the io cache (wx + oob, bin.cache=true and reopen all re-parse the original .rela.plt), so it needs a new binary in testbins — happy to add one if you want it.

  1. No double-flag happens. ends stays true for a direct branch, so the call resets entry/lea_ptr and the trailing jmp sees UT64_MAX; !r_flag_get would drop a second flag anyway. Instrumented: exactly three calls per fixture, all size=12, in both layouts. The 17 is the later function analysis resizing the flag, so the lazy golden never pinned the scanner — the -z now golden (12) is the one that does. You're right it was implicit, so there's now a comment above the test.

  2. op.jump at sec_vaddr == 0: unset is UT64_MAX, not 0 — r_anal_op always calls r_anal_op_init (libr/anal/op.c:127, libr/arch/arch_op.c:93), and a failed decode returns oplen < 1 before the switch. UT64_MAX < sec_end is false unless the section wraps, so the guard can't fire. Left it out rather than add something dead (but obviously can add).

  3. Dropped at all three sites (RAnalOp.ptr is st64, so > 0 covers it).

Also folded the oplen < 1 early-continue into the same if/else chain, so per-entry state resets in exactly one place instead of two — that was the drift behind (1).

One more found while in there: R_ANAL_OP_TYPE_CJMP is JMP | COND and type is masked with ~COND, so a conditional branch was reaching the new case — replacing the lazy stub's call with a jne to the same thunk still produced sym.plt.add2. Now guarded with !(op.type & R_ANAL_OP_TYPE_COND) in the same condition, so a conditional branch still terminates an entry but no longer resolves a slot. Test added, 1 XX with the guard reverted. The indirect path masks COND the same way — that's #26457's code and I have no fixture for it, so I left it; happy to follow up.

@trufae
trufae merged commit 52692e4 into radareorg:master Aug 15, 2026
52 checks passed
@trufae

trufae commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Nice!

@phix33
phix33 deleted the anal-plt-retpoline branch August 15, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants