はじめに
半年ほど前にはなりますが、SECCON Beginners CTF 2024に参加しました。
普段業務ではほとんど触らないような領域だったこともあって、ほとんど解くことができませんでした。
その中で特に興味を持ったreversing
カテゴリのassemble
という問題について、解き方と仕組みを解説します。
この記事では「アセンブリで文字列を出力する」ところまでやりたいので、Step4は対象外でStep3まで挑戦します。(Step4に関しては別の記事に書く予定です。)
SECCON Beginners CTF 2024
の問題は、こちらのリポジトリにあり、対象の問題はassembleになります。
問題概要
Intel記法でアセンブリ言語のプログラムを書いて、flag(特定のfileに存在する文字列)を出力できるようにする問題です。
問題はStep1 ~ Step4まで段階的に進めていき、Step4でflag.txt
の内容を出力できたら達成です。
今回はStep3までで、アセンブリ言語で特定の文字列を標準出力に出力できるようにするところまでです。
Step1
Challenge 1. Please write 0x123 to RAX!
- Only mov, push, syscall instructions can be used.
- The number of instructions should be less than 25.
Step1は愚直にRAXレジスタに対して0x123
を書き込むだけなので、以下のようになります。
mov rax, 0x123
Step2
Challenge 2. Please write 0x123 to RAX and push it on stack!
- Only mov, push, syscall instructions can be used.
- The number of instructions should be less than 25.
Step2も愚直問題で、0x123
を書き込んだRAXをメモリのスタック領域に対してpushします。
mov rax, 0x123
push rax
Step3
Challenge 3. Please use syscall to print Hello on stdout!
- Only mov, push, syscall instructions can be used.
- The number of instructions should be less than 25.
Step3ではsystem callを呼び出してHello
という文字列を標準出力に出力します。
write(2)
標準出力に文字列を出力する際には、STDOUTに対する書き込みを行なっているわけですがこのときに呼び出されているのが、write
システムコールです。
echoなどの標準出力に出力するようなコマンドを実行し、システムコールをトレースするとわかりやすいです。
$ strace -e write echo "hello world"
write(1, "hello world\n", 12hello world
) = 12
+++ exited with 0 +++
↑ を見るとwrite
を呼び出す際に引数が3つ渡されていますが、これらは何でしょうか?
このwrite
のインターフェースについて見ていきます。
ドキュメントを見ると、以下のように記載されています。
ssize_t write(int fd, const void buf[.count], size_t count);
write() writes up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.
第一引数が出力先のfile descriptor、第二引数がbuffer(の先頭アドレス)、第三引数が対象のbufferからfile descriptorに対して書き込むサイズを表していることがわかります。
今回の場合を考えると、以下のような引数になればいいはずです。
-
fd
: 1(STDOUTのfile descriptorの番号) -
buf
: Helloという文字列の先頭アドレス -
count
: 6(Hello + null終端)
syscallの呼び出し
write
の際に必要な引数がわかったので、次はアセンブリ言語でどのように書いたらwrite
を特定の引数で呼び出せるかです。
今回の問題では、x86_64
を想定しているのでx86_64
のシステムコールのテーブルを参照します。
上記のテーブルを見ると、write
を呼び出すためにレジスタの状態が以下のようになっている必要があります。
-
rax
: 0x01 -
rdi
: unsigned int fd -
rsi
: const char *buf -
rdx
: size_t count
これも同様に今回の場合を考えると以下のようになります。
-
rax
: 0x01 -
rdi
: 0x01 -
rsi
: Helloという文字列の先頭アドレス -
rdx
: 0x06
文字列の取り扱い
mov
, push
, syscall
命令だけしか使えず、dataセクションなどにHello
という文字列をそのまま書くことはできないので、HelloをASCIIコードに変換して扱う必要があります。
Helloの文字をそれぞれASCIIコードに変換すると、0x48
, 0x65
, 0x6c
, 0x6c
, 0x6f
になります。
これに加えて、x86_64
アーキテクチャにおいてはリトルエンディアンで扱う必要があるので、Helloという文字列を表現しようとすると0x6f6c6c6548
になります。
Step3の回答
必要な情報が揃ったので問題を解いていきます。
まずは、Helloという文字列をスタック領域にpushします。
mov rax, 0x6f6c6c6548
push rax
この時点で、スタック領域にデータが積み上がったのでRSP(スタックポインタ)の値が「Helloという文字列の先頭アドレス」になります。
RBP(ベースポインタ),RSPとスタック領域の関係を図にすると以下のようになります。
残りは、各レジスタに所定の値を入れていけば良いので以下のようになります。
mov rax, 0x6f6c6c6548
push rax
mov rax, 0x01
mov rdi, 0x01
mov rsi, rsp
mov rdx, 0x06
これで条件を満たすようなシステムコールを呼び出す準備ができたので、残りはsyscall命令のみです。
mov rax, 0x6f6c6c6548
push rax
mov rax, 0x01
mov rdi, 0x01
mov rsi, rsp
mov rdx, 0x06
syscall
参考