0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

アセンブリと戯れた記録

Last updated at Posted at 2023-01-15

前書き

某日, (アセンブリ書きてえ!)と思ったので書いてました. その振り返りと記録です.

環境

zsh
> uname -snrmo
Linux archlinux 6.0.5.14.realtime1-2-rt x86_64 GNU/Linux

たまたま linux-rt を使ってますが, お気になさらず…

zsh
> nasm -v
NASM version 2.15.05 compiled on Sep 24 2020
zsh
> 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)
zsh
> 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 です.

zsh
> nasm -f elf64 main.asm -o main.o
> mold main.o -o main
> ./main

実行すると改行で flash されるまでの最大 32 バイトを読み込んで吐き出してくれます.

zsh
> ./main
Hi, echo!
Hi, echo!

改行も読み込むのでちょっとびっくりした (改行コード忘れの % が見当たらなかったから違和感). 32 バイトを超えると単に読み込まなくなる. (多分, 少なくとも吐き出しはしない).

zsh
> ./main
01234567890123456789012345678901234567890
01234567890123456789012345678901%

ちゃんと 32 バイト目までしか吐き出されてないですね, % は改行されずに終了した痕跡です (改行はコード側ではしていないので).

詰まった所

  • 取り敢えず C みたいに return しとけば終わるかな〜とか思ったら終わらなかった (SEGV)
  • ret の挙動を分かってなかった (C の吐くアセンブリ見て適当にレジスタ弄ってたら変になっちゃった)
  • mov QWORD PTR [rbp-32], 0 って書き方がまんまだと動かなくて狼狽えた (QWORD [...] でした, 参考は忘れた)

C で入力から任意関数を呼び出させる

要約: 脆弱性を生み出して, 攻撃する (したかった).

初期実装

まず書いたのがこれ:

attack.c
#include <stdio.h>

int main(void) {
    char s[8] = {};

    scanf("%s", s);

    return 0;
}

で, ここから stdin で境界外アクセスを誘発させ, 何かのアドレスを書き換えてやろう!と意気込んだのですが… あれ, そもそも何を書き換えれば良いか知らないや.

調査

取り敢えず境界外アクセスさせよう.

zsh
> gcc attack.c -o attack
zsh
> ./attack
12345678

これは大丈夫.

zsh
> ./attack
1234567890123456
*** stack smashing detected ***: terminated
zsh: IOT instruction (core dumped)  ./attack

うん, なんか取り敢えずめっちゃ書き込めば怒られる. …でもただ SEGV するだけだな…
じゃあ次に, 取り敢えずバイナリを覗いてみよう.

zsh
> 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 は何かと言うと, maincall した際に格納された戻りアドレスのことです. 攻撃にうってつけの場所.
%rbppush rbp で格納された call 元の base pointer のこと.
canary (呼び名はよく分かってない) は …後述.
<zero>mov QWORD PTR [rbp-0x10],0x0 によってゼロ埋めされています. 後に解りますがこれは char s[8] の領域のことです.

では順を追ってスタックの様子を考えてみましょう.

data:  [retadr]
rbp:
rsp:          ^

^%rbp, %rsp の指している番地を表しています.
今, maincall した直後です. %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 1040scanf を呼び出す処理へ繋がる
  • %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 であり, jerdx の値が false に評価できる時にジャンプを行います.
つまりこの場合, scanf("%s", s) がバッファオーバーランを起こした場合にのみ call 1030 に到達することとなります (これは debugger でも確認できます). つまり, *** stack smashing detected *** というメッセージはまさにこの呼び出しに到達したことを意味しているのです (多分).

話が逸れましたが, 最後に leaveret について. 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:  ^

最後に retretadr%rip を上書きします. これにより呼び出し元へ戻ることとなります.

data:  [[   before   ]][retadr][ %rbp ][canary][<zero>]
rbp:  ^
rsp:                  ^

(上の図は概略的です, before が呼び出し元のローカル変数の領域を表しています)
一見良くわからない領域を指しているように感じられるかもしれませんが, つまるところ呼び出し元の関数について, main 冒頭の状態と同じようにしていることが分かります. こう処理を追っていくと "スタックに積まれる" っていうのがどういうことかよく分かるなあなんて (分からない?そう…).

境界外アクセスの容認

先に述べましたが, canary を用いたバッファオーバーラン機構により ret に辿り着く前にプロセスが殺されてしまいます. ので, 参考に書いてあるとおり -fno-stack-protector を付けて gcc でコンパイルをかけます.

zsh
> gcc -fno-stack-protector attack.c -o attack

では, バイナリを覗いてみましょう.

zsh
> 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 バイトはローカル変数の領域を確保するっぽい. なんで? (別に良いけど…)
では, 実行してみましょう:

zsh
> ./attack
1234567890123456
zsh: segmentation fault (core dumped)  ./attack

無事保護機構を貫通することが出来ました.

※逆に言うと, 保護機構をつけておけばその関数内のローカル変数で起きたバッファオーバーランはすぐに検知することが出来ます. これは main に限らずついてくるので, 保護機構は外さず付けておきましょう.

簡易実装

stdin から番地を上書きするのは なんかうまく行かなかった ので, コード内で直接書き換えます (え?).
取り敢えず main を呼び出させます.

attack.c
#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>][??????]

こうなりますね. ではコンパイルしたものを実行してみましょう.

zsh
> ./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 された retadrpop されたまま main の先頭に戻っていることにあります. 本来であれば関数の先頭に来るということは call された後のはずですが, 今は ret の戻り先で指定されているので, その分 %rsp がずれているのです.

そして, これをなんとなくの考えと気合で解決してみました (冷静に考えたらちょっとおかしいのだけど).

参考:

attack.c
#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 分ずれています. これで動くようになるとでも?
コンパイルしたものを実行してみましょう.

zsh
> ./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 も正常だし, 何なんでしょう?

まあ, なんか満足したしええか!

他参考

おわりに

アセンブリ楽しい.

皆様も良きアセンブリライフを.

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?