前書き
某日, (アセンブリ書きてえ!)と思ったので書いてました. その振り返りと記録です.
環境
> uname -snrmo
Linux archlinux 6.0.5.14.realtime1-2-rt x86_64 GNU/Linux
たまたま linux-rt を使ってますが, お気になさらず…
> nasm -v
NASM version 2.15.05 compiled on Sep 24 2020
> gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-pc-linux-gnu/12.2.0/lto-wrapper
Target: x86_64-pc-linux-gnu
Configured with: /build/gcc/src/gcc/configure --enable-languages=c,c++,ada,fortran,go,lto,objc,obj-c++,d --enable-bootstrap --prefix=/usr --libdir=/usr/lib --libexecdir=/usr/lib --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=https://bugs.archlinux.org/ --with-build-config=bootstrap-lto --with-linker-hash-style=gnu --with-system-zlib --enable-__cxa_atexit --enable-cet=auto --enable-checking=release --enable-clocale=gnu --enable-default-pie --enable-default-ssp --enable-gnu-indirect-function --enable-gnu-unique-object --enable-libstdcxx-backtrace --enable-link-serialization=1 --enable-linker-build-id --enable-lto --enable-multilib --enable-plugin --enable-shared --enable-threads=posix --disable-libssp --disable-libstdcxx-pch --disable-werror
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 12.2.0 (GCC)
> lldb -v
lldb version 14.0.6
アセンブリで stdin を stdout へ書き出す
何かのツールを模してやろうとかいうわけじゃなかったので, 特に意味は無いです. 強いて言うなら Hello, World! は飽きたので…というくらい.
前提: syscall について
参考: yamnikov-oleg/calling_conventions.md
arch/ABI | arg1 | arg2 | arg3 | arg4 | arg5 | arg6 | arg7 | Notes |
---|---|---|---|---|---|---|---|---|
x86_64 | rdi | rsi | rdx | r10 | r8 | r9 | - |
つまりこう:
_return = syscall(rdi)(rdi, rsi, rdx, r10, r8, r9);
number | abi | name | entry point |
---|---|---|---|
0 | common | read | sys_read |
1 | common | write | sys_write |
60 | common | exit | sys_exit |
引数などの詳細まで書いてなかったので man を見る. あと clangd が吐く定義を見てる.
read
参考: read(2) — Arch manual pages
ssize_t read(int fd, void *buf, size_t count);
write
参考: write(2) — Arch manual pages
ssize_t write(int fd, const void *buf, size_t count);
exit
参考: exit(2) — Arch manual pages
void exit(int status);
前提: レジスタについて
参考: CPU Registers x86-64 - OSDev Wiki #General Purpose Registers
Monikers (64-bit) | Description |
---|---|
RAX | Accumulator |
RBX | Base |
RCX | Counter |
RDX | Data (commonly extends the A register) |
RSI | Source index for string operations |
RDI | Destination index for string operations |
RSP | Stack Pointer |
RBP | Base Pointer (meant for stack frames) |
R8 | General purpose |
R9 | General purpose |
R10 | General purpose |
R11 | General purpose |
R12 | General purpose |
R13 | General purpose |
R14 | General purpose |
R15 | General purpose |
実装
あとは気合で書く. 書いた. コメントの関数シグネチャ風記法は Rust っぽく書いた.
global _start
section .text
; _start()
_start:
mov rbp, rsp
sub rsp, 32
lea rdi, [rbp-32]
call init
lea rdi, [rbp-32]
call read
lea rdi, [rbp-32]
call write
call exit
; init(*mut char)
init:
mov QWORD [rdi+24], 0
mov QWORD [rdi+16], 0
mov QWORD [rdi+ 8], 0
mov QWORD [rdi+ 0], 0
; rbp[0..32] fills 0;
ret
; read(*const char)
read:
mov r15, rdi
mov rax, 0
mov rdi, 0
mov rsi, r15
mov rdx, 32
syscall
; read(stdin, r15, 32);
ret
; write(*mut char)
write:
mov r15, rdi
mov rax, 1
mov rdi, 1
mov rsi, r15
mov rdx, 32
syscall
; write(stdout, rbp[-32], 32);
ret
; exit() -> !
exit:
mov rax, 60
mov rdi, 0
syscall
; exit(0);
詳しい命令諸々は Compiler Explorer (※permalink) でも見れば分かる (分からない).
read
, write
, exit
はまんま syscall のラッパをしている. 別に引数を全部とって unistd.h
みたいに…という訳ではなく, 単に操作を wrap してるだけ.
そしてこんな感じで実行. mold は rui314/mold です.
> nasm -f elf64 main.asm -o main.o
> mold main.o -o main
> ./main
実行すると改行で flash されるまでの最大 32 バイトを読み込んで吐き出してくれます.
> ./main
Hi, echo!
Hi, echo!
改行も読み込むのでちょっとびっくりした (改行コード忘れの %
が見当たらなかったから違和感). 32 バイトを超えると単に読み込まなくなる. (多分, 少なくとも吐き出しはしない).
> ./main
01234567890123456789012345678901234567890
01234567890123456789012345678901%
ちゃんと 32 バイト目までしか吐き出されてないですね, %
は改行されずに終了した痕跡です (改行はコード側ではしていないので).
詰まった所
- 取り敢えず C みたいに return しとけば終わるかな〜とか思ったら終わらなかった (SEGV)
- ret の挙動を分かってなかった (C の吐くアセンブリ見て適当にレジスタ弄ってたら変になっちゃった)
-
mov QWORD PTR [rbp-32], 0
って書き方がまんまだと動かなくて狼狽えた (QWORD [...]
でした, 参考は忘れた)
C で入力から任意関数を呼び出させる
要約: 脆弱性を生み出して, 攻撃する (したかった).
初期実装
まず書いたのがこれ:
#include <stdio.h>
int main(void) {
char s[8] = {};
scanf("%s", s);
return 0;
}
で, ここから stdin で境界外アクセスを誘発させ, 何かのアドレスを書き換えてやろう!と意気込んだのですが… あれ, そもそも何を書き換えれば良いか知らないや.
調査
取り敢えず境界外アクセスさせよう.
> gcc attack.c -o attack
> ./attack
12345678
これは大丈夫.
> ./attack
1234567890123456
*** stack smashing detected ***: terminated
zsh: IOT instruction (core dumped) ./attack
うん, なんか取り敢えずめっちゃ書き込めば怒られる. …でもただ SEGV するだけだな…
じゃあ次に, 取り敢えずバイナリを覗いてみよう.
> objdump -dM intel attack
attack: file format elf64-x86-64
# --- snip ---
0000000000001149 <main>:
1149: 55 push rbp
114a: 48 89 e5 mov rbp,rsp
114d: 48 83 ec 10 sub rsp,0x10
1151: 64 48 8b 04 25 28 00 mov rax,QWORD PTR fs:0x28
1158: 00 00
115a: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
115e: 31 c0 xor eax,eax
1160: 48 c7 45 f0 00 00 00 mov QWORD PTR [rbp-0x10],0x0
1167: 00
1168: 48 8d 45 f0 lea rax,[rbp-0x10]
116c: 48 89 c6 mov rsi,rax
116f: 48 8d 05 8e 0e 00 00 lea rax,[rip+0xe8e] # 2004 <_IO_stdin_used+0x4>
1176: 48 89 c7 mov rdi,rax
1179: b8 00 00 00 00 mov eax,0x0
117e: e8 bd fe ff ff call 1040 <__isoc99_scanf@plt>
1183: b8 00 00 00 00 mov eax,0x0
1188: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
118c: 64 48 2b 14 25 28 00 sub rdx,QWORD PTR fs:0x28
1193: 00 00
1195: 74 05 je 119c <main+0x53>
1197: e8 94 fe ff ff call 1030 <__stack_chk_fail@plt>
119c: c9 leave
119d: c3 ret
# --- snip ---
なるほどわからん.
参考:
push rbp
mov rbp,rsp
sub rsp,0x10
mov rax,QWORD PTR fs:0x28
mov QWORD PTR [rbp-0x8],rax
xor eax,eax
mov QWORD PTR [rbp-0x10],0x0
これがまず関数の冒頭の処理. スタックの様子はこんな感じ:
data: [retadr][ %rbp ][canary][<zero>]
^ . . . . . . . ^ . . . . . . . ^
rbp: +0x10 -0x00 -0x10
上の行が格納されてるデータの模式図, 下の行が %rbp
(base pointer) からの相対位置を示しています.
retadr
は何かと言うと, main
を call
した際に格納された戻りアドレスのことです. 攻撃にうってつけの場所.
%rbp
は push rbp
で格納された call
元の base pointer のこと.
canary
(呼び名はよく分かってない) は …後述.
<zero>
は mov QWORD PTR [rbp-0x10],0x0
によってゼロ埋めされています. 後に解りますがこれは char s[8]
の領域のことです.
では順を追ってスタックの様子を考えてみましょう.
data: [retadr]
rbp:
rsp: ^
^
が %rbp
, %rsp
の指している番地を表しています.
今, main
を call
した直後です. %rbp
の番地は不明です (多分図よりもっと左側を指しています).
data: [retadr][ %rbp ]
rbp:
rsp: ^
%rbp
をスタックに載せます.
data: [retadr][ %rbp ]
rbp: ^
rsp: ^
%rbp
を %rsp
で書き換えます.
data: [retadr][ %rbp ][??????????????]
rbp: ^
rsp: ^
-0x10
だけ %rsp
の指す先が移動します.
data: [retadr][ %rbp ][canary][??????]
rbp: ^
rsp: ^
fs:0x28
から canary
を読み出して, スタックの末尾に書き込みます.
data: [retadr][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
最後に char s[8]
をゼロで埋めて, 冒頭の処理は完了です.
lea rax,[rbp-0x10]
mov rsi,rax
lea rax,[rip+0xe8e] # 2004 <_IO_stdin_used+0x4>
mov rdi,rax
mov eax,0x0
call 1040 <__isoc99_scanf@plt>
mov eax,0x0
次に, scanf("%s", s)
の処理. ざっくり言うと:
-
scanf(%rdi, %rsi)
と呼び出している-
%rdi
は別の場所に格納されている"%s"
の番地 -
%rsi
は先にゼロ埋めされた領域, つまりchar s[8]
の先頭番地 -
call 1040
でscanf
を呼び出す処理へ繋がる
-
-
%eax
は帰り値- 何も処理してないので全部ゼロ埋めされちゃってる
mov rdx,QWORD PTR [rbp-0x8]
sub rdx,QWORD PTR fs:0x28
je 119c <main+0x53>
call 1030 <__stack_chk_fail@plt>
leave
ret
最後に関数の末尾の処理. スタックの図を再掲.
data: [retadr][ %rbp ][canary][<zero>]
^ . . . . . . . ^ . . . . . . . ^
rbp: +0x10 -0x00 -0x10
mov rdx,QWORD PTR [rbp-0x8]
はまず canary
を %rdx
に読み出す. そして sub rdx,QWORD PTR fs:0x28
で先に読み出したはずの canary
同士の差を取っている. …って, え?
参考:
まず fs:0x28
について.
%fs
は前述した General Purpose Registers とは違う, Segment Registers と呼ばれるレジスタです. :0x28
というのはオフセットを指しているそう.
%fs
, というか Segment Registers は環境に依って使われ方が違うそうですが, Linux では (恐らく glibc により) fs:0x28
からバッファオーバーランの保護機構として用いられる canary
という値が読み出せます.
ではどう使われているかと言うと, 先に示した図の通りローカル変数の領域は -0x08 .. -0x10
ですが, 例えば scanf("%s", s)
で 8 文字以上の入力を受け取ったとします (つまりヌル文字を考慮すると 9 文字以上に相等).
するとこの char s[8]
を超えた書き込みは canary
の領域に行われます. しかしこれでは, 関数の冒頭で fs:0x28
から読み出した値と, 関数の末尾で確認する canary
の値は異なってしまいます.
これは sub rdx,QWORD PTR fs:0x28
によって %rdx
に差が取られるので, 0
でないなら値が異なっていることとなります. 注目すべきはこの次にある je 119c
であり, je
は rdx
の値が false に評価できる時にジャンプを行います.
つまりこの場合, scanf("%s", s)
がバッファオーバーランを起こした場合にのみ call 1030
に到達することとなります (これは debugger でも確認できます). つまり, *** stack smashing detected ***
というメッセージはまさにこの呼び出しに到達したことを意味しているのです (多分).
話が逸れましたが, 最後に leave
と ret
について. leave
は関数冒頭で行った push rbp
, mov rbp,rsp
とは逆の処理である mov rsp,rbp
, pop rbp
を行います.
具体的には, base pointer を呼び出し元のものに戻すということです (ざっくり). 次に ret
は, pop rip
を行うので, つまり retadr
を読み出して, 次に実行する命令の番地を指定していることになります.
図を書き換えながら様子を考えてみると:
data: [retadr][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
^
が %rbp
, %rsp
の指している番地を表しています.
今, leave
の直前の状態です.
data: [retadr][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
leave
により, まず %rsp
が %rbp
の値で書き換えられます.
data: [retadr][ %rbp ][canary][<zero>]
rbp:
rsp: ^
次に %rbp
がスタック上の値で書き換えられます (図上では書いていませんが, 多分図の左側のどこかを指しているでしょう).
data: [retadr][ %rbp ][canary][<zero>]
rbp:
rsp: ^
最後に ret
で retadr
が %rip
を上書きします. これにより呼び出し元へ戻ることとなります.
data: [[ before ]][retadr][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
(上の図は概略的です, before
が呼び出し元のローカル変数の領域を表しています)
一見良くわからない領域を指しているように感じられるかもしれませんが, つまるところ呼び出し元の関数について, main
冒頭の状態と同じようにしていることが分かります. こう処理を追っていくと "スタックに積まれる" っていうのがどういうことかよく分かるなあなんて (分からない?そう…).
境界外アクセスの容認
先に述べましたが, canary
を用いたバッファオーバーラン機構により ret
に辿り着く前にプロセスが殺されてしまいます. ので, 参考に書いてあるとおり -fno-stack-protector
を付けて gcc
でコンパイルをかけます.
> gcc -fno-stack-protector attack.c -o attack
では, バイナリを覗いてみましょう.
> objdump -dM intel attack
attack: file format elf64-x86-64
# --- snip ---
0000000000001139 <main>:
1139: 55 push rbp
113a: 48 89 e5 mov rbp,rsp
113d: 48 83 ec 10 sub rsp,0x10
1141: 48 c7 45 f8 00 00 00 mov QWORD PTR [rbp-0x8],0x0
1148: 00
1149: 48 8d 45 f8 lea rax,[rbp-0x8]
114d: 48 89 c6 mov rsi,rax
1150: 48 8d 05 ad 0e 00 00 lea rax,[rip+0xead] # 2004 <_IO_stdin_used+0x4>
1157: 48 89 c7 mov rdi,rax
115a: b8 00 00 00 00 mov eax,0x0
115f: e8 cc fe ff ff call 1030 <__isoc99_scanf@plt>
1164: b8 00 00 00 00 mov eax,0x0
1169: c9 leave
116a: c3 ret
# --- snip ---
なんか処理が小さくなったような気がしますが, まんまさっきの保護機構が消えているだけです. ついでにちょっとスタックの様子も変わっています.
data: [retadr][ %rbp ][<zero>][??????]
^ . . . . . . . ^ . . . . . . . ^
rbp: +0x10 -0x00 -0x10
なんかしらんけど 0x10 バイトはローカル変数の領域を確保するっぽい. なんで? (別に良いけど…)
では, 実行してみましょう:
> ./attack
1234567890123456
zsh: segmentation fault (core dumped) ./attack
無事保護機構を貫通することが出来ました.
※逆に言うと, 保護機構をつけておけばその関数内のローカル変数で起きたバッファオーバーランはすぐに検知することが出来ます. これは main
に限らずついてくるので, 保護機構は外さず付けておきましょう.
簡易実装
stdin から番地を上書きするのは なんかうまく行かなかった ので, コード内で直接書き換えます (え?).
取り敢えず main
を呼び出させます.
#include <stdio.h>
int main(void) {
char s[8] = {};
scanf("%s", s);
((void **)s)[2] = main;
return 0;
}
先のスタックの図を見ましょう.
data: [retadr][ %rbp ][<zero>][??????]
^ . . . . . . . ^ . . . . . . . ^
rbp: +0x10 -0x00 -0x10
^ ^ ^
s: [2] [1] [0]
char *
を void **
にキャストしているので, ポインタ演算は sizeof(void *) = 8
バイト単位で行われます.
そして [2]
を指定しているので, ちょうど retadr
の位置に main
の先頭番地を書き込んでいます. つまり:
data: [ main ][ %rbp ][<zero>][??????]
こうなりますね. ではコンパイルしたものを実行してみましょう.
> ./attack
12345678
zsh: segmentation fault (core dumped) ./attack
…
妥協実装
まず, 何が起こっているのかを説明しましょう. 普段は起き得ないことですが, まあ変なことをしているのでこういうこともあるのです.
では, 関数一回分の処理でスタックがどのように変移したかを再掲しましょう:
data: [[ before ]][retadr][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
正常に main
が終了すれば, 状態は呼び出し元の関数に準じたものに戻るはずです. しかし, 今は retadr
を書き換えています.
data: [[ before ]][ main ][ %rbp ][canary][<zero>]
rbp: ^
rsp: ^
では, ここから main
を始めてみましょう. まず冒頭の処理をしてみます.
data: [[ before ]][ %rbp ][<zero>][??????]
rbp: ^
rsp: ^
さっきより -0x08 分ずれていますね. 比較してみましょう.
prev data: [[ before ]][ main ][ %rbp ][<zero>][??????]
^ . . . . . . . ^ . . . . . . . ^
prev rbp: +0x10 -0x00 -0x10
new data: [[ before ]][ %rbp ][<zero>][??????]
^ . . . . . . . ^ . . . . . . . ^
new rbp: +0x10 -0x00 -0x10
このずれの正体は call
によって push
された retadr
が pop
されたまま main
の先頭に戻っていることにあります. 本来であれば関数の先頭に来るということは call
された後のはずですが, 今は ret
の戻り先で指定されているので, その分 %rsp
がずれているのです.
そして, これをなんとなくの考えと気合で解決してみました (冷静に考えたらちょっとおかしいのだけど).
参考:
#include <stdio.h>
size_t offset = 0;
int main(void) {
char s[8] = {};
asm("sub %0, %%rsp" ::"r"(offset));
scanf("%s", s);
((void **)s)[2] = main;
offset += 8;
return 0;
}
要は, 呼出回数に応じて %rsp
をずらしています. これによって %rsp
の位置は何度 ret
を繰り返しても同様に維持することが出来ました. …で? %rbp
は変わらず -0x08 分ずれています. これで動くようになるとでも?
コンパイルしたものを実行してみましょう.
> ./attack
a
b
c
d
e
f
g
h
i
# --- snip ---
なんか動く. SEGV しない. なんで?
ハックを消して, SEGV している部分を観察してみる.
# --- snip ---
movq %rdx, -0x6b8(%rbp)
movl %ecx, -0x658(%rbp)
movq %fs:0x28, %rax
movq %rax, -0x38(%rbp)
xorl %eax, %eax
movq 0x17be74(%rip), %rax
movq %rsi, -0x5f0(%rbp)
movq %fs:(%rax), %rax
movq %rax, -0x628(%rbp)
movq (%rax), %rax
movq %r13, -0x460(%rbp)
movdqu (%rdx), %xmm1
movq %rax, -0x678(%rbp)
movups %xmm1, -0x5a8(%rbp)
movq 0x10(%rdx), %rax
movq $0x400, -0x458(%rbp) ; imm = 0x400
movq %rax, -0x598(%rbp)
movl 0xc0(%rdi), %eax
--> movaps %xmm1, -0x600(%rbp)
# --- snip ---
この movaps
が処理されると SEGV しますが, しかしそもそも正常に動作していればこの処理 (関数) は実行されないのです …????? じゃあ, これは何で呼び出されてるんだよ?
SEGV, SEGV と言っていますが正確には, debugger 使用時に signal SIGSEGV: invalid address (fault address: 0x0)
というメッセージが出ているのでこう呼んでいます (余談).
%rbp
も正常だし, 何なんでしょう?
まあ, なんか満足したしええか!
他参考
おわりに
アセンブリ楽しい.
皆様も良きアセンブリライフを.