Skip to content

本页便签全部 ↗

本页还没有便签

划选正文里的一段文字,点弹出的「✎ 存为便签」,回头在这里一键跳回原处。

Hitting Farther: The Canary, Saved rbp, and the Return Address ​

The out-of-bounds write in the previous piece landed close by and hurt only one data neighbor. We're using the same environment as in the previous piece, so follow that piece if you want to rerun anything. Next question: what happens when the write lands farther out? We made the index come in from the command line, compiled the same source twice, once with the defaults (canary on, where a canary is a sentinel value the stack protector plants) and once with -fno-stack-protector (protection taken off), and we measured the index conversions for both layouts straight off the disassembly:

Expand codeCollapse22 lines
C++
// d3.cpp: out-of-bounds writes that reach the canary / saved rbp / the return address
// the index comes from the command line. The same source is compiled twice,
// once with the default (stack protector on) and once with -fno-stack-protector
#include <cstdio>
#include <cstdlib>

volatile int g_index   = 0;
volatile int g_payload = 0x1234567; // the value written into the target slot, a bad address whose low 32 bits are easy to recognize

__attribute__((noinline))
void f() {
    int arr[4] = {1, 2, 3, 4};
    arr[g_index] = g_payload; // the out-of-bounds write
    std::fprintf(stderr, "store done (idx=%d), now return\n", (int)g_index);
}

int main(int argc, char** argv) {
    g_index = std::atoi(argv[1]);
    f();
    std::fprintf(stderr, "main: returned from f normally\n");
    return 0;
}

All of d3's progress printing goes to stderr, which is unbuffered, so lines already printed survive even when the process dies partway through. Here is f from each build (the fprintf argument-shuffling stretch is elided, and the build labels at the end of the function header lines are ours):

text
$ g++ -O0 -g -o d3_default d3.cpp
$ g++ -O0 -g -fno-stack-protector -o d3_noprot d3.cpp
asm
0000000000001169 <f()>:                (default build, canary on)
    1169:	push   %rbp
    116a:	mov    %rsp,%rbp
    116d:	sub    $0x20,%rsp
    1171:	mov    %fs:0x28,%rax
    117a:	mov    %rax,-0x8(%rbp)
    117e:	xor    %eax,%eax
    1180:	movl   $0x1,-0x20(%rbp)
    ... (arr[1..3] initialization, elided) ...
    11aa:	mov    %edx,-0x20(%rbp,%rax,4)
    ... (fprintf argument shuffling, elided) ...
    11d3:	mov    -0x8(%rbp),%rax
    11d7:	sub    %fs:0x28,%rax
    11e0:	je     11e7 <f()+0x7e>
    11e2:	call   1040 <__stack_chk_fail@plt>
    11e7:	leave
    11e8:	ret
asm
0000000000001159 <f()>:                (unprotected build)
    1159:	push   %rbp
    115a:	mov    %rsp,%rbp
    115d:	sub    $0x10,%rsp
    1161:	movl   $0x1,-0x10(%rbp)
    1168:	movl   $0x2,-0xc(%rbp)
    116f:	movl   $0x3,-0x8(%rbp)
    1176:	movl   $0x4,-0x4(%rbp)
    117b:	mov    0x2ea5(%rip),%edx        # 4028 <g_payload>
    1183:	mov    0x2ec3(%rip),%eax        # 404c <g_index>
    1189:	cltq
    118b:	mov    %edx,-0x10(%rbp,%rax,4)
    118f:	mov    0x2eb7(%rip),%edx        # 404c <g_index>
    ... (fprintf argument shuffling, elided) ...
    11b3:	nop
    11b4:	leave
    11b5:	ret

The default build's f has the same shape as the f in the previous piece's d1: the canary sits at rbp-0x8 and the array base is at rbp-0x20. In the unprotected build the canary is gone, the array base has moved to rbp-0x10, and the frame has shrunk to 0x10. The index conversions for both layouts (the address of arr[k] equals the array base plus 4k) are in this table:

TargetDefault build (base rbp-0x20)Unprotected build (base rbp-0x10)
canary (rbp-0x8)k = 6none (this build has no canary)
saved rbp (rbp+0x0)k = 8k = 4
return address (rbp+0x8)k = 10k = 6

Hitting the Canary: The Write Succeeds, the Return Is Intercepted ​

text
$ ./d3_default 6
store done (idx=6), now return
*** stack smashing detected ***: terminated
exit=134

The write succeeded and the progress line came out. Then glibc's __stack_chk_fail path printed that line of asterisks to stderr, and the process ended in SIGABRT with exit code 134. Watch the timing: the canary check happens before f returns, and the return address was intact from start to finish. The line of defense stood in front of it. Where that asterisk line comes from was covered in the evidence discipline in the environment section of the first piece, and the full call-chain evidence waits in the smashed-canary section of the next piece.

Hitting the Return Address: Control Flow Hijacked on the Spot ​

text
$ ./d3_noprot 6
store done (idx=6), now return
exit=139

One progress line printed, the exit code came back 139, and there was no other text in the shell, which is what a non-interactive shell should give you. This build has no canary, so the same k=6 write lands directly on the low 4 bytes of the return address slot. Let's have gdb gather evidence the whole way through:

text
$ gdb -batch -q \
      -ex 'set debuginfod enabled off' \
      -ex 'break d3.cpp:13' -ex run \
      -ex 'printf "== 写入之后、返回之前 ==\n"' \
      -ex 'info frame' -ex 'x/12gx $rbp-0x28' \
      -ex 'printf "== continue 到崩溃现场 ==\n"' \
      -ex continue \
      -ex 'info registers rip rsp rbp' \
      -ex 'printf "== 崩溃时 rsp 周围 ==\n"' \
      -ex 'x/6gx $rsp-0x10' -ex bt \
      --args ./d3_noprot 6 2>&1 | grep -v 'libthread_db\|Thread debugging'

(Housekeeping lines such as Breakpoint 1 at have been removed. The == separator lines likewise come from the printf commands we told gdb to run, and everything else is verbatim.)

Expand codeCollapse34 lines
text
Breakpoint 1, f () at d3.cpp:13
13	    std::fprintf(stderr, "store done (idx=%d), now return\n", (int)g_index);
== 写入之后、返回之前 ==
Stack level 0, frame at 0x7fffffffd0a0:
 rip = 0x55555555518f in f (d3.cpp:13); saved rip = 0x555501234567
 called by frame at 0x7fffffffd0a8
 source language c++.
 Arglist at 0x7fffffffd090, args:
 Locals at 0x7fffffffd090, Previous frame's sp is 0x7fffffffd0a0
 Saved registers:
  rbp at 0x7fffffffd090, rip at 0x7fffffffd098
0x7fffffffd068:	0x0000000000000002	0x00007ffff7ffd000
0x7fffffffd078:	0x0000555555557da8	0x0000000200000001
0x7fffffffd088:	0x0000000400000003	0x00007fffffffd0b0
0x7fffffffd098:	0x0000555501234567	0x00007fffffffd1e8
0x7fffffffd0a8:	0x00000002f7ebd538	0x00007fffffffd160
0x7fffffffd0b8:	0x00007ffff7827781	0x00007ffff7ecf248
== continue 到崩溃现场 ==
store done (idx=6), now return

Program received signal SIGSEGV, Segmentation fault.
0x0000555501234567 in ?? ()
rip            0x555501234567      0x555501234567
rsp            0x7fffffffd0a0      0x7fffffffd0a0
rbp            0x7fffffffd0b0      0x7fffffffd0b0
== 崩溃时 rsp 周围 ==
0x7fffffffd090:	0x00007fffffffd0b0	0x0000555501234567
0x7fffffffd0a0:	0x00007fffffffd1e8	0x00000002f7ebd538
0x7fffffffd0b0:	0x00007fffffffd160	0x00007ffff7827781
#0  0x0000555501234567 in ?? ()
#1  0x00007fffffffd1e8 in ?? ()
#2  0x00000002f7ebd538 in ?? ()
#3  0x00007fffffffd160 in ?? ()
Backtrace stopped: previous frame identical to this frame (corrupt stack?)

Let's line up the key spots. The pre-return info frame reports saved rip = 0x555501234567. That used to be an address inside main (somewhere in the 0x5555555552xx range). The low 32 bits have been overwritten by the payload 0x01234567, and the high 32 bits are still the original 0x00005555. This is exactly the scene a 4-byte int write leaves behind, with half the return address ruined. The memory dump shows it just as plainly. The 0x...d078 and 0x...d088 rows spell out arr's 1, 2, 3, 4. 0x...d090 is the intact saved rbp (0x00007fffffffd0b0), and right next to it, 0x...d098 is the corrupted return address slot, 0x0000555501234567. After continue the program takes a SIGSEGV, the rip at the crash site matches the written-in bad address verbatim, and bt can no longer walk the stack (corrupt stack?). For what hijacked control flow looks like, you will not find more direct evidence than this.

Hitting saved rbp: The Crash Moves to the Caller ​

text
$ ./d3_noprot 4
store done (idx=4), now return
main: returned from f normally
exit=139

This scene deserves a closer look: f itself returned normally (the return address was fine) and main's progress line printed as well, yet the exit code came back 139. We put the breakpoint where main has just taken control back (the command has the same shape as the previous scene, with the breakpoint moved to d3.cpp:19, the threading-library notice lines filtered the same way, the housekeeping lines removed, and everything else verbatim):

text
store done (idx=4), now return

Breakpoint 1, main (argc=<error reading variable: Cannot access memory at address 0x7fff01234563>, argv=<error reading variable: Cannot access memory at address 0x7fff01234557>) at d3.cpp:19
19	    std::fprintf(stderr, "main: returned from f normally\n");
== f 已返回、main 刚接回控制权 ==
rbp            0x7fff01234567      0x7fff01234567
rsp            0x7fffffffd0a0      0x7fffffffd0a0
== main 帧里残存的 f 帧:rbp-0x10 起 ==
0x7fff0123455f:	Cannot access memory at address 0x7fff0123455f
== continue 看接下来发生什么 ==
main: returned from f normally

Program received signal SIGSEGV, Segmentation fault.
main (argc=<error reading variable: Cannot access memory at address 0x7fff01234563>, argv=<error reading variable: Cannot access memory at address 0x7fff01234557>) at d3.cpp:21
21	}
rip            0x555555555208      0x555555555208 <main(int, char**)+82>
rsp            0x7fffffffd0a0      0x7fffffffd0a0
rbp            0x7fff01234567      0x7fff01234567
#0  main (argc=<error reading variable: Cannot access memory at address 0x7fff01234563>, argv=<error reading variable: Cannot access memory at address 0x7fff01234557>) at d3.cpp:21
Backtrace stopped: Cannot access memory at address 0x7fff0123456f

Let's reconstruct what happened. The pop %rbp in f's epilogue loaded the bad value 0x7fff01234567 into the rbp register. The return address was intact, so main could keep running, but from that point on main had lost the ability to address anything through rbp, and gdb could not even read argc and argv on its behalf (Cannot access memory). In the end it died on main's own epilogue: leave treats the corrupted rbp as rsp and pops from it, and that is where the SIGSEGV landed. The write that corrupted saved rbp was arr[4]'s out-of-bounds write. f returned normally, and the one that died is main, the function that called it. This late-crash tier is the one most likely to fool you during debugging, because the crash site sits one function return away from where the out-of-bounds write happened, and the stack data there has long since been overwritten by later frames. All three distant targets have had their turn. In a real process this hijacking ends in SIGSEGV, and reproducing one is not cheap, so we staged a controlled version in the page: the executor below loads the unprotected build of f, where the arr[6] out-of-bounds write covers the return-address slot with boom's address, and f's ret pops something that is no longer main's next instruction. For a clean landing, main pre-pushes an exit address before the call, boom does its work and rides it down onto the guard, leaving 99 in rax. Two things differ from the article's scene: the real program corrupts only the low 32 bits and jumps to a wild address, while here the whole address is a valid boom, and the canaried build depends on fs segment addressing, which the executor does not emulate, so the scene carries only the unprotected build. Single-step to f's ret, look at the slot on top of the stack window already holding boom's address, take one more step, and see where rip lands.

CPU · X86-64

汇编执行器

第 0 步滚动到此处后加载

准备好后,在这里亲手观察一次函数调用。

第 0 步 · 每步一条真实指令

As for the canary standing in front of the return address, how it gets planted and how it gets checked is what the next piece looks at head-on.

pdf-latest-41-gc317b3f · c317b3f · 2026-10-24