チームpiccccoで参加してGlobal 8位、JP Students 4位でした。たぶん4位になる呪いがかかっています。
自分が解いた分のwriteupです。 ダークモードを多用しているのと、フラグは画像から目コピしてるので間違ってても許してhosii。
Web Exploitation
WebSockFish
Can you win in a convincing manner against this chess bot? He won't go easy on you!
らしい。
ブラウザでおさかなとチェスができる。
普通にチェックメイトするだけではYou may eventually chackmate me, but you will never break my spirit as a fish!!などと言われる。
お前はもう負けてるんだよ。
Burpで通信内容を確認したところ、セッション管理などは存在せず、打った手の良しあしをweb socketで送信していた。
よくわからないが、evalの値が小さいほど手が良いみたいな感じだったので、-999999...を送ったらおさかなをわからせることができた。

picoCTF{c1i3nt_s1d3_w3b_s0ck3t5_b820bcc2}
Apriti sesamo
I found a web app that claims to be impossible to hack!
とのことで、ログインページに誘導される。
ソースコードを見てもヘッダを見ても特に何もない。
うーん???と悩みつつ色々試していたら、パラメータ名に[]をつけることでパラメータを配列として認識してくれた。
どうやらusernameもSHA1で保存しているらしく、sha1()の結果がnullとかになってnull===nullみたいな感じにログインできるんだと思う。
picoCTF{w3Ll_d3sErV3d_Ch4mp_7d3c7e1c}
Pachinko
History has failed us, but no matter.
らしい。
インスタンスを立ち上げると「NAND Simulator」なるページに誘導される。あと、ソースコードも公開されている。 フラグが2つ存在し、1つがこの問題用で、もう1つがRevisited用らしい。
てきとうに遊んでたらフラグが降ってきたのでこれはパチンコです。
詳細についてはPachinko Revisitedで解説します。
Pachinko Revisited
We cannot help but be interested in the stories of people that history pushes aside so thoughtlessly. Find all artifacts and instance connections in Pachinko. Submit flag two here.
らしい。
Pachinkoは本当にパチンコで猿でも解ける内容だったが、今回はさすがにそうはいかない。
配布されているserverのコードを見ると、Nodeからwasmのcpuエミュレータを呼び出して色々しているらしい。
配布ファイルには、nand_checker.binとflag.binというデータも含まれており、それぞれがCPUエミュレータ内で利用される。
コードを読む
まずはindex.jsの/checkエンドポイントから、どのようにデータが処理されるかを確認する。
app.post('/check', async (req, res) => { const circuit = req.body.circuit; if (!Array.isArray(circuit) || !circuit.every(entry => checkInt(entry?.input1) && checkInt(entry?.input2) && checkInt(entry?.output))) { return res.status(400).end(); } const program = await fs.readFile('./programs/nand_checker.bin'); // Generate random input state with only 0x0000 or 0xffff values const inputState = new Uint16Array(4); for (let i = 0; i < 4; i++) { inputState[i] = Math.random() < 0.5 ? 0x0000 : 0xffff; } // Create output state as inverse of input const outputState = new Uint16Array(4); for (let i = 0; i < 4; i++) { outputState[i] = inputState[i] === 0xffff ? 0x0000 : 0xffff; } const serialized = serializeCircuit( circuit, program, inputState, outputState ); doRun(res, serialized); });
// Circuit validation utilities function checkInt(value) { if (value === undefined) return false; if (typeof value !== 'number') return false; if (value !== Math.floor(value)) return false; return value > 0 && value <= 0xFFFF; }
inputState
→ 4つの値が、0x0000または0xFFFFからランダムに生成される。outputState
→ 単純にinputStateのNOTとなる。checkInt
→ 整数として0x0001以上、0xFFFF以下であるかをガチガチにチェックしており、ここに手を加えるのはたぶん無理。
serializeCircuitは64KBのUint8Arrayの先頭に./programs/nand_checker.bin、0x1000にoutputStateの長さとoutputState、0x2000+5にinputState、0x3000に送信されてきたcircuitを順に書き込んでいる。
function serializeCircuit(circuit, program, inputState, outputState) { const memory = new Uint8Array(65536); // 64KB memory // Copy program at start memory.set(program); // Serialize output state at 0x1000 const outputView = new Uint16Array(memory.buffer, 0x1000); outputView[0] = outputState.length; outputView.set(outputState, 1); // Serialize input state at 0x2000 const inputView = new Uint16Array(memory.buffer, 0x2000); inputView.set(inputState, outputState.length + 1); // Serialize circuit at 0x3000 const circuitView = new Uint16Array(memory.buffer, 0x3000); circuit.forEach((gate, i) => { const offset = i * 3; circuitView[offset] = gate.input1; circuitView[offset + 1] = gate.input2; circuitView[offset + 2] = gate.output; }); return memory; }
そして、doRunはこんな感じにFLAGを出す条件を判定している。
function doRun(res, memory) { const flag = runCPU(memory); const result = memory[0x1000] | (memory[0x1001] << 8); if (memory.length < 0x1000) { return res.status(500).json({ error: 'Memory length is too short' }); } let resp = ""; if (flag) { resp += FLAG2 + "\n"; } else { if (result === 0x1337) { resp += FLAG1 + "\n"; } else if (result === 0x3333) { resp += "wrong answer :(\n"; } else { resp += "unknown error code: " + result; } } res.status(200).json({ status: 'success', flag: resp }); }
memory[0x1000]とmemory[0x1001]から値を取り出し、その値が0x1337ならFLAG1が返される。- 一方、
runCPUの結果がtrueの場合はFLAG2が返される。
runCPUは、WASM側にexportされたprocess()関数に対して、CPU内部状態(state)を渡して実行している。
最大500,000サイクルで、各サイクルごとにsignals.clockを反転させることでクロック動作を実現している。
function runCPU(memory) { const state = new Uint8Array(100_000); const signals = loadCpuSignals(); // Reset sequence process(state); state[signals.reset] = 255; process(state); state[signals.reset] = 0; process(state); let flag = false; const MAX_CYCLES = 500000; for (let cycle = 0; cycle < MAX_CYCLES; cycle++) { // Toggle clock state[signals.clock] ^= 255; process(state); // On clock low edge if (state[signals.clock] === 0) { // Handle memory writes if (state[signals.write_enable] === 255) { const addr = getBitsValue(state, signals.addr); const val = getBitsValue(state, signals.out_val); memory[addr] = val & 0xFF; memory[addr + 1] = (val >> 8) & 0xFF; } // Handle memory reads const addr = getBitsValue(state, signals.addr); const [first_byte, second_byte] = splitBits(signals.inp_val, 8); setBits(state, first_byte, memory[addr]); setBits(state, second_byte, memory[addr + 1]); // Check halted and flag if (state[signals.halted] === 255) { break; } if (state[signals.flag] === 255) { flag = true; } } } return flag; }
じゃあそのsignalsは?となるのだが、../verilog/cpu.jsonは配布ファイルに含まれていなかった。
function loadCpuSignals() { const json = JSON.parse(fs.readFileSync(path.join(__dirname, '../verilog/cpu.json'), 'utf8')); return { clock: getBitFromJson(json, "clock"), addr: getBitsFromJson(json, "addr"), inp_val: getBitsFromJson(json, "inp_val"), out_val: getBitsFromJson(json, "out_val"), reset: getBitFromJson(json, "reset"), write_enable: getBitFromJson(json, "write_enable"), halted: getBitFromJson(json, "halted"), flag: getBitFromJson(json, "flag"), }; }
wasmの生成元のrustのコードを見ても、特に情報は得られない。
use wasm_bindgen::prelude::*; use verilog_macro::synth_cpu; #[wasm_bindgen] pub extern "C" fn process(ptr: &mut [u8]) -> u32 { let raw_ptr = ptr.as_mut_ptr(); let nand = |a: usize, b: usize, y: usize| unsafe { *raw_ptr.add(y) = !(*raw_ptr.add(a) & *raw_ptr.add(b)); }; synth_cpu!("../../verilog/cpu.json", nand); 0 }
nand_checker.binやflag.binもこんな感じでよくわからない。

これまでのコードに脆弱性がありそうな場所は無かったので、wasmを何とかする必要がありそう。
signalsの特定
signalsのアドレスを復元しないと手元環境でCPUを動かすことすらできない。 なので、それを何とかする。
配布ファイルのverilog_ctf_wasm_bg.wasmをwasm2watすると何かわかるかなと願ったが、exportされているprocess関数は
~snip~ local.get 3 local.get 4 i32.const -1 i32.xor i32.store8 offset=4834 local.get 3 local.get 4 local.get 6 i32.and local.tee 8 i32.const -1 i32.xor i32.store8 offset=4832 ~snip~
みたいに値を読み込んで論理演算して書き込むというのを3万行くらい繰り返しており、人が読むものではない。 その前後にもいろいろ処理はあるが、テーブル操作やstateを線形メモリに配置するなどをやっている気がする。
とりあえずChromeで動的解析すると、このloadやstoreのベースアドレス($3)が先のstateが読み込まれているアドレスと同じであった。
なので、signalsのアドレスもこれらのoffsetと連動している。
論理演算している部分はCPUの回路そのものっぽいので、読み込み、書き込みに利用しているオフセットの関係性を調べればある程度signalsを絞り込めそうである。
特にsignals.clockやsignals.resetなんかはread onlyである可能性が高い。
ということでそのあたりの静的解析を自動化するプログラムを作った。
loadに利用されているoffset、storeに利用されているoffset、loadのみ、storeのみに利用されているoffsetの一覧をtxtに出力する。
cpu.watは該当の論理演算を繰り返している部分を切り出したものである。
from typing import List, Dict, Tuple import sys class Expression: def __str__(self) -> str: raise NotImplementedError class Local(Expression): def __init__(self, index: int) -> None: self.index = index def __str__(self) -> str: return str(self.index) class Constant(Expression): def __init__(self, value: int) -> None: self.value = value def __str__(self) -> str: return str(self.value) class BinaryOp(Expression): def __init__(self, op: str, left: Expression, right: Expression) -> None: self.op = op self.left = left self.right = right def __str__(self) -> str: return f"({self.left} {self.op} {self.right})" class MemoryLoad(Expression): def __init__(self, effective_address: str) -> None: self.effective_address = effective_address def __str__(self) -> str: return f"load({self.effective_address})" class WasmEmulator: def __init__(self) -> None: self.stack: List[Expression] = [] self.locals: Dict[int, Expression] = {} self.load_addresses: List[str] = [] self.store_addresses: List[str] = [] def get_local_value(self, index: int) -> Expression: if index in self.locals: return self.locals[index] else: return Local(index) def _parse_offset(self, tokens: List[str]) -> int: for token in tokens: if token.startswith("offset="): try: return int(token.split("=")[1]) except ValueError: raise ValueError(f"Invalid offset value in token: {token}") return 0 def eval_instruction(self, line: str) -> None: line = line.strip() if not line or line.startswith(";;") or line.startswith(";"): return tokens = line.split() if not tokens: return instr = tokens[0] if instr == "local.get": if len(tokens) < 2: raise ValueError("local.get requires an argument") try: local_index = int(tokens[1]) except ValueError: raise ValueError(f"Invalid local index: {tokens[1]}") value = self.get_local_value(local_index) self.stack.append(value) elif instr == "local.tee": if len(tokens) < 2: raise ValueError("local.tee requires an argument") if not self.stack: raise RuntimeError("Stack underflow on local.tee") try: local_index = int(tokens[1]) except ValueError: raise ValueError(f"Invalid local index: {tokens[1]}") value = self.stack.pop() self.locals[local_index] = value self.stack.append(value) elif instr == "local.set": if len(tokens) < 2: raise ValueError("local.set requires an argument") if not self.stack: raise RuntimeError("Stack underflow on local.set") try: local_index = int(tokens[1]) except ValueError: raise ValueError(f"Invalid local index: {tokens[1]}") value = self.stack.pop() self.locals[local_index] = value elif instr == "i32.const": if len(tokens) < 2: raise ValueError("i32.const requires an argument") try: const_val = int(tokens[1]) except ValueError: raise ValueError(f"Invalid integer constant: {tokens[1]}") self.stack.append(Constant(const_val)) elif instr in {"i32.and", "i32.xor", "i32.or"}: if len(self.stack) < 2: raise RuntimeError(f"Stack underflow on {instr}") right = self.stack.pop() left = self.stack.pop() op = instr.split(".")[-1] self.stack.append(BinaryOp(op, left, right)) elif instr.startswith("i32.load"): offset = self._parse_offset(tokens) if not self.stack: raise RuntimeError("Stack underflow on load") base = self.stack.pop() if isinstance(base, Local): effective_address = f"{base.index}+{offset}" else: effective_address = f"({base})+{offset}" self.load_addresses.append(effective_address) self.stack.append(MemoryLoad(effective_address)) elif instr.startswith("i32.store"): offset = self._parse_offset(tokens) if len(self.stack) < 2: raise RuntimeError("Stack underflow on store") _value = self.stack.pop() base = self.stack.pop() if isinstance(base, Local): effective_address = f"{base.index}+{offset}" else: effective_address = f"({base})+{offset}" self.store_addresses.append(effective_address) else: raise ValueError(f"Unknown instruction: {instr}") def eval_code(self, code: str) -> None: lines = code.strip().splitlines() for line_no, line in enumerate(lines, start=1): try: self.eval_instruction(line) except Exception as e: print(f"Error at line {line_no}: {line.strip()}") print(f" {e}") sys.exit(1) def get_results(self) -> Tuple[List[str], List[str], List[str], List[str]]: def unique(seq: List[str]) -> List[str]: seen = set() result = [] for item in seq: if item not in seen: seen.add(item) result.append(item) return result load_list = unique(self.load_addresses) store_list = unique(self.store_addresses) load_only = [addr for addr in load_list if addr not in store_list] store_only = [addr for addr in store_list if addr not in load_list] return load_list, store_list, load_only, store_only def main() -> None: try: with open("cpu.wat", "r") as f: wat_code = f.read() except Exception as e: print(f"Failed to open cpu.wat: {e}") sys.exit(1) emulator = WasmEmulator() emulator.eval_code(wat_code) load_list, store_list, load_only, store_only = emulator.get_results() with open("load_addresses.txt", "w") as f: for addr in load_list: print(addr, file=f) with open("store_addresses.txt", "w") as f: for addr in store_list: print(addr, file=f) with open("load_only.txt", "w") as f: for addr in load_only: print(addr, file=f) with open("store_only.txt", "w") as f: for addr in store_only: print(addr, file=f) if __name__ == "__main__": main()
load_onlyとして次の結果が得られた。それ以外のtxtはサイズが大きかったのでこの場では省略する。
19 20 22 21 25 24 23 29 27 28 34 33 32 31 30 26 68 2
→ 2と68、および19から34がloadのみの対象となっている。
getBitsValue()やsetBits()の使われ方から、 signals.addr、signals.inp_val、signals.out_valは16bitの値である。
恐らく19から34はinp_valである。そして、2と68のどちらかがclockでもう片方がresetである。

また、16アドレス以上連続しているload、storeを探索するプログラムを作成した結果、storeに使われているアドレスのうち、これらが判明した。
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18] [35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67] [69, ~snip~, 4481]
とりあえずclockを2、resetを68とした。
また、loadCpuSignals()内で、addr、inp_val、out_valの順番でシグナルを列挙しているため、
- addr: 3~18
- inp_val: 19~34
- out_val: 35~50
としてconsole.logデバッグしながら動かしてみると、うまく動いた。
さらに各サイクルのstateをWinMergeで見比べ、次のことがわかった。
pc: 51~66write_enable: 69halted: 70flag: 71
これまでの結果を元に、loadCpuSignals()を次のように書き変える。
x_は何かありそうだと思って置いた値だが、結局使わなかったので無視してね。
export function loadCpuSignals() { return { clock: 2, reset: 68, addr: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], inp_val: [19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34], out_val: [35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50], pc: [51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66], write_enable: 69, halted: 70, flag: 71, x_67: 67, x_72: 72, x_73: 73, x_74: 74, x_75: 75, x_76: 76, x_77: 77, x_78: 78, }; }
レジスタの特定
とりあえずは実行できるようになったので、恐らく存在するであろうレジスタのアドレスを特定する。
もう一度nand_checker.binを見てみると、最初に?D 00 ?? ??のようなデータが存在している。
![]()
後半部分は00 30であったり00 20であったり33 33や37 13と、リトルエンディアンで即値を読み込んでいるように見える。
(0x3000や0x2000はmemory上のcircuitやoutputStateのアドレス、0x3333と0x1337はresultの値)
そこで、nD 00はn番レジスタに後続の2bytesを即値として読み込む命令だという仮説を立てた。
0D 00 00 00と0D 00 FF FFを実行した場合の差異をWinMergeで確認する。
cycle=5で、次のような違いが表れた。

0xb1cからの部分と0xf21からの部分で、大きく変化が表れている。 0xb1cからの部分は1bitが3byte、0xf21からの部分は1bitが9byteのように思える。全体としてどのような意味を持つかは不明だが、変化箇所の最初の値を確認することで、レジスタの値を取得できそうである。
stateをダウンロードしてはWinMergeで比較する作業にそろそろうんざりしていたので、Bunでデバッガを作る事にした。Bunにした理由は特にない。
0xb1cからの部分はうまく取得できていなかったが、0xf21からの部分を利用するとうまくいきそう。
↓flag.binのデバッグ画面

命令の解析
命令の実行前後でレジスタ、PCの変化を比較し、各命令の意味を推測して以下のようなニーモニックを作成した。
n1 mm : add r<n>, r<m> r<n> の値に r<m> の値を加算し、その結果を r<n> に格納する。 n4 mm : addi r<n>, <imm> r<n> の値に即値 <imm> を加算し、その結果を r<n> に格納する。 n6 mm : nand r<n>, r<m> r<n> と r<m> の値に対して論理積を取り、その結果の各ビットを反転(NAND)し r<n> に格納する。 n7 mm : cmp_gt r<n>, <imm> r<n> の値が即値 <imm> より大きい場合、 r<n> に 1 を設定し、そうでなければ 0 を設定する。 n8 mm : ldis r<n>, <imm> <imm>の値をx番レジスタに即値読み込み。 n9 mm : store [r<n>], r<m> r<n> に含まれる値をメモリアドレスとして、そのアドレスに r<m> の値を書き込む。 nB mm : load r<n>, [r<m>] r<m> に含まれる値をメモリアドレスとして、そのアドレスから値を読み込み r<n> に格納する。 nC mm : jz r<n>, <imm> r<n> の値が 0 である場合、即値 <imm> の示すアドレスにジャンプする。 nD 00 <2byte> : ldi rx <num> numの値をx番レジスタに即値読み込み。 0E 00 : flag(?) R0からR3レジスタを確認しflagに適した状態であればflagシグナルを有効化する。 0F 00 : halt プログラムの終了。
そしてnand_checker.binをディスアセンブルすると
こういう感じのプログラムが判明する。
- 最初にcircuit内の値チェック(0x1000未満であること)
- circuit内で、入力アドレスの2倍+0x2000をオフセットとして
inputStateの値を取得し、NANDして出力アドレスに書き込む。 - 全てのgateを処理後、
inputStateとoutputStateの比較を行い、全て一致していれば0x1337、不一致なら0x3333を0x1000に書き込み、その後haltする。
つまり、FLAG1は「NANDでinputStateのNOTを作成するcircuit」を送信すると得られる仕組みになっている。
なので、このようなcircuitをsubmitするとFLAG1を入手できる。
[ { "input1": 5, "input2": 5, "output": 1 }, { "input1": 6, "input2": 6, "output": 2 }, { "input1": 7, "input2": 7, "output": 3 }, { "input1": 8, "input2": 8, "output": 4 } ]
ここまで長かった。
exploit
FLAG1の取得方法は判明したものの、今回必要なのはFLAG2である。
flag.binをどうにかして実行するか、stateを同じ状態にする必要がある。
とりあえずwasmを元にsignals.flagと思われるアドレスを解析したが、人が見るものではなかった。
なので、flag.binを実行する方針に切り替えた。
circuitが入力・出力に利用する領域の仕組みに着目すると次のことが分かる。
- circuit内で指定する入力・出力のアドレスは2倍される
- 送信前に0x1000未満であることが強制されるが、その後に2倍されるため、結果として、
circuit自身の読み書きが可能。0x0000~0x0FFFの値を書き込める。- 同様に、NOTすることで
0xF000~0xFFFFの値も利用可能で、これを2倍するとオーバーフローが発生し、programが格納されている0x0000付近の値を書き変えられる。
(0x0000はJS側で弾かれるが、なにも保存していないアドレスは必然的に0なので可能。)
この方針でうまくいきそうだ。
signals.flagを有効にする方法はflag.binの実行意外には見つけられなかったので、変更後のコードはflag.binと同じ動作をする必要がある。
ldi命令、flag命令、halt命令は全てリトルエンディアンで最初のバイトが0x00になるので問題ない。
しかし、レジスタに書き込む即値は0x1000以上の値になるので、そのまま利用する事は出来ない。
そこで、即値を上位(例: 6f)と下位(例: 73)に分割する。
6fと73、63と65...
0x6f00は6f0をR0に代入した後4回(add r0, r0)すればよい。
addi r0, 0x73をすれば0x6f73になるが、addiだと即値のせいで0x0fffを越えてしまうのでR4レジスタに書き込んだ値をadd r0, r4する。
ldi r0, 0x6f0は0x06f0 0x000d
ldi r1, 0x630は0x0630 0x001d
ldi r2, 0x690は0x0690 0x002d
ldi r3, 0x6f0は0x06f0 0x003d
add r0, r0は0x0001
add r1, r1は0x0111
add r2, r2は0x0221
add r3, r3は0x0331
ldi r4, 0x73は0x0073 0x004d
ldi r4, 0x65は0x0065 0x004d
ldi r4, 0x2eは0x0063 0x004d
最後は00なので何もしなくてよい。
最終的なアセンブリは次のようになる。
ldi r0, 0x6f0 ldi r4, 0x73 add r0, r0 add r0, r0 add r0, r0 add r0, r0 add r0, r4 ldi r1, 0x630 ldi r4, 0x65 add r1, r1 add r1, r1 add r1, r1 add r1, r1 add r1, r4 ldi r2, 0x690 ldi r4, 0x2e add r2, r2 add r2, r2 add r2, r2 add r2, r2 add r2, r4 ldi r3, 0x6f0 add r3, r3 add r3, r3 add r3, r3 add r3, r3 flag halt
これをハンドアセンブルして

実行すると、signals.flagが1(255)になることを確認する。
上手くいってそう。
あとはこのプログラム注入するcircuitを作成すればよい。
そのためにこんな感じのcircuitを考える。
------- circuit ~~~~~~~ injector ~~~~~~~ payload ~~~~~~~ temp -------
circuitはinjectorの準備、injectorはcircuit実行ループ後のアドレスにコード書き込み、payloadは書き込むコードや、アドレスのNOTを格納しておく。
最初のcircuitでpayload領域に配置した命令コードをNANDしてtemp領域に書き込む。temp領域は0x0500以降にした。
次に2倍して+0x2000したら0x004c以降のアドレスになる値をNOTした値を、circuitでNOTしてinjectorのoutput部分に書き込む。
これでinjectorが完成し、injectorによって0x004c以降が書き換わる。
最後にpayload領域が実行されないようにpayload領域の最初に0を書き込んでおく。
(このあたりは書いてる自分でもよくわからなくなります。)
メモリ上のcircuitはこんな感じ

これを実行すると

書き変えることに成功した。

あとは作成したcircuitを問題サーバーに投げつけるとflagがもらえる。

picoCTF{p4ch1nk0_r3v15173d_flag_two_a6c19d0d}
実際に使ったcircuitとかdebuggerはgithubで公開しています。
Cryptography
Guess My Cheese (Part 1)
自分が解いたのはPart 2ですが一応Part 1の解説も。
ncで問題サーバーに接続すると、いろいろ言われた後に
Here's my secret cheese -- if you're Squeexy, you'll be able to guess it: LXBRFUSWPCVFOOK Hint: The cheeses are top secret and limited edition, so they might look different from cheeses you're used to! Commands: (g)uess my cheese or (e)ncrypt a cheese What would you like to do?
と言われる。この暗号部分は毎回変化する。
eを送信し、適当なチーズの名称を送信すると何らかの暗号化が行われた結果を返される。 そこからguessしてAffine cipherだということに気付き、問題のチーズを復号しろということらしい。
ちなみに入力可能なチーズの一覧はPart 2の説明文からダウンロードできる。なんで?
Affine cipherはAffine Cipher - Online Decryption, Decoder, Encoder, Calculatorを使うと総当たりで簡単に解ける。
結果はQUESOTBLANCORRHだが、replace(")", "C").replace(" ", "T").replace("(", "B").replace("'", "A").replace("-","G").replace(",","F")みたいな変換が行われてるので、それを戻して答える必要がある。面倒だったら記号類が使われていないチーズが出るまで回してください。
Guess My Cheese (Part 2)
The imposter was able to fool us last time, so we've strengthened our defenses!
らしい。
問題サーバーに接続すると、
Here's my secret cheese -- if you're Squeexy, you'll be able to guess it: 2ef056a4f9c218ae6dc8d42b1e68aaca9d19b88835e906cc7112262bb06bd87a Commands: (g)uess my cheese What would you like to do?
のように言われる。 見た感じSHA256とかそのあたりのハッシュ値で、適当に答えると
Remember, this is my encrypted cheese: 2ef056a4f9c218ae6dc8d42b1e68aaca9d19b88835e906cc7112262bb06bd87a So...what's my cheese? Blue Annnnd...what's my salt?
のようにsaltが含まれていることが判明した。
チーズ一覧をPart 1同様にaffine cipherしたりrock youを組み合わせたり、同じ長さの他のハッシュを試すなど色々したが、無理だった。
仕方がなくヒントを見ると、SHA256で2 nibbles of hexadecimal-character saltらしい。
わからんて。
しかしそれでも解けなかったので、いくつかハッシュを収集して完全な総当たりを行うと、チーズをlower caseにした後にバイナリとして1byteをsaltに使っていることが判明した。 わからんて。
後はそういう感じにマスクを組むとhashcatで解けます。
.\hashcat.exe -m 1400 -a 6 -w 3 hash.txt cheese_list.txt ?b
picoCTF{cHeEsY20f87415}
guessがすぎる
Reverse Engineering
Quantum Scrambler
We invented a new cypher that uses "quantum entanglement" to encode the flag. Do you have what it takes to decode it?
らしい。
次のようなプログラムが渡される。
import sys def exit(): sys.exit(0) def scramble(L): A = L i = 2 while (i < len(A)): A[i-2] += A.pop(i-1) A[i-1].append(A[:i-2]) i += 1 return L def get_flag(): flag = open('flag.txt', 'r').read() flag = flag.strip() hex_flag = [] for c in flag: hex_flag.append([str(hex(ord(c)))]) return hex_flag def main(): flag = get_flag() cypher = scramble(flag) print(cypher) if __name__ == '__main__': main()
ncで問題サーバーにアクセスするとこのプログラムで生成したcypherが表示される。
普通にscramble()の逆を行えば良さそう。
def reverse_scramble(L): A = L[:] i = len(A) - 1 while i >= 2: A[i - 1].pop() A.insert(i - 1, [A[i - 2].pop(-1)]) i -= 1 return A def reverse_hex_flag(hex_flag): flag = '' for c in hex_flag: flag += chr(int(c[0], 16)) return flag def main(): cypher=[出力されたデータ] plain = reverse_scramble(cypher) print(plain) print(reverse_hex_flag(plain)) if __name__ == '__main__': main()
これを実行するとフラグがもらえる。
[['0x70'], ['0x69'], ['0x63'], ['0x6f'], ['0x43'], ['0x54'], ['0x46'], ['0x7b'], ['0x70'], ['0x79'], ['0x74'], ['0x68'], ['0x6f'], ['0x6e'], ['0x5f'], ['0x69'], ['0x73'], ['0x5f'], ['0x77'], ['0x65'], ['0x69'], ['0x72'], ['0x64'], ['0x66'], ['0x61'], ['0x37'], ['0x62'], ['0x34'], ['0x61'], ['0x31'], ['0x65'], ['0x7d']]
picoCTF{python_is_weirdfa7b4a1e}
Binary Instrumentation 1
I have been learning to use the Windows API to do cool stuff! Can you wake up my program to get the flag?
らしい。
てきとうなサンドボックス環境で配られたexeを実行すると眠りについてしまう。

とりあえずx64dbでデバッグしてみる。 このあたりが怪しそう。

call rbxの先はこんな感じ

大量のsleepが存在しており、実行が進まない。

こんな感じにsleepする前にjmpするように書き換える。

するとbase64されたフラグがもらえる。
picoCTF{w4ke_m3_up_w1th_fr1da_f27acc38}
実は命令を書き変えなくてもsleep前に既にセグメントレジスタにフラグが入っている。

Binary Instrumentation 2
I've been learning more Windows API functions to do my bidding. Hmm... I swear this program was supposed to create a file and write the flag directly to the file. Can you try and intercept the file writing function to see what went wrong?
らしい。
サンドボックス環境でプログラムを実行するとすぐに終了してしまう。 Binary Instrumentation 1と同じアドレスの関数を確認してみる。

CreateFileAとかWriteFile当たりの実行がうまくいってないのかもしれないが、普通にデータセグメントにbase64されたフラグが入っていた。
picoCTF{fr1da_f0r_bin_in5trum3nt4tion!_b21aef39}
Frida...
perplexed
Download the binary here.
らしい。
シンプルなリバース
pwndbgで適当に見てみる。
checkという関数があり、入力値の長さが0x1bであることや、比較用の値をスタックに挿入していることが分かる。値の挿入されるアドレスがrbp-0x41とちょっとアレ。

比較処理はこのあたり。

IDAのデコンパイル結果はこんな感じで、2重ループになっている。
if ( strlen(a1) != 27 ) return 1LL; v3 = 0x617B2375F81EA7E1LL; v4[0] = 0xD269DF5B5AFC9DB9LL; *(_QWORD *)((char *)v4 + 7) = 0xF467EDF4ED1BFED2LL; v11 = 0; v10 = 0; v7 = 0; for ( i = 0; i <= 0x16; ++i ) { for ( j = 0; j <= 7; ++j ) { if ( !v10 ) v10 = 1; v6 = 1 << (7 - j); v5 = 1 << (7 - v10); if ( (v6 & *((char *)&v4[-1] + (int)i)) > 0 != (v5 & a1[v11]) > 0 ) return 1LL; if ( ++v10 == 8 ) { v10 = 0; ++v11; } v2 = v11; if ( v2 == strlen(a1) ) return 0LL; } } return 0LL;
変に悩んでも仕方がないので、スタックの内容をそのまま出力して確認する。
pwndbg> x/32x $rbp-0x60 0x7fffffffd0f0: 0x00000000 0x00000000 0xffffd160 0x00007fff 0x7fffffffd100: 0xf81ea7e1 0x617b2375 0x5afc9db9 0xd269df5b 0x7fffffffd110: 0xf4ed1bfe 0x00f467ed 0x00403e00 0x00000000
そして、IDAのデコンパイル結果とアセンブリの処理をもとに、逆の処理になるようにsolverを書く。
def solve(): stack = [ 0xe1, 0xa7, 0x1e, 0xf8, 0x75, 0x23, 0x7b, 0x61, 0xb9, 0x9d, 0xfc, 0x5a, 0x5b, 0xdf, 0x69, 0xd2, 0xfe, 0x1b, 0xed, 0xf4, 0xed, 0x67, 0xf4 ] result = bytearray(27) i = 0 # 参照データのバイトindex m = 0 # inp のバイトindex k = 0 # inp のビット(0..7)を逆から使う制御 while i <= 22: for j in range(8): if k == 0: k = 1 bitRef = (stack[i] >> (7 - j)) & 1 result[m] &= ~(1 << (7 - k)) if bitRef == 1: result[m] |= (1 << (7 - k)) k += 1 if k == 8: k = 0 m += 1 if m == 27: return bytes(result) i += 1 return bytes(result) if __name__ == "__main__": print("Flag:", solve())
Flag: b'picoCTF{0n3_bi7_4t_a_7im3}\x00'
Forensics
Event-Viewing
One of the employees at your company has their computer infected by malware! Turns out every time they try to switch on the computer, it shuts down right after they log in. The story given by the employee is as follows: 1. They installed software using an installer they downloaded online 2. They ran the installed software but it seemed to do nothing 3. Now every time they bootup and login to their computer, a black command prompt screen quickly opens and closes and their computer shuts down instantly.
See if you can find evidence for the each of these events and retrieve the flag (split into 3 pieces) from the correct logs!
らしい。
Bsse64してるなら「=」が含まれているんじゃないかという憶測をもとにEvent Log Explorerでつきすすむ。
このフィルタリングだけでだいぶ絞れた。

picoCTF{Ev3nt_vi3wv3r_1s_a_pr3tty_us3ful_t00l_81ba3fe9}
Bitlocker-1
Jacky is not very knowledgable about the best security passwords and used a simple password to encrypt their BitLocker drive. See if you can break through the encryption!
らしい。
簡単なパスワードということなので、bitlocker2johnで出力されたハッシュをrockyouを辞書にして解析する。
.\bitlocker2john.exe -i A:\bitlocker-1.dd
.\hashcat.exe -m 22100 .\hash.txt .\rockyou.txt
するとパスワードが分かった。

これをdislockerでマウントすればフラグを閲覧できる。
sudo dislocker -r -V /mnt/a/bitlocker-1.dd -ujacqueline -- ~/bitlocker_dislocker sudo mount -o loop ~/bitlocker_dislocker/dislocker-file ~/bitlocker_mount

picoCTF{us3_b3tt3r_p4ssw0rd5_pl5!_3242adb1}
Bitlocker-2
Jacky has learnt about the importance of strong passwords and made sure to encrypt the BitLocker drive with a very long and complex password. We managed to capture the RAM while this drive was opened however. See if you can break through the encryption!
らしい。
Bitlockerのddイメージと共にメモリダンプを渡される。 MemProcFSで色々見てたらデスクトップに回復キーを出力している痕跡があったが、その内容まではわからなかった。
何かツールがあるだろと思って調べてたらVolatilityのプラグインを見つけた。 breppo/Volatility-BitLocker: Volatility plugin to retrieve the Full Volume Encryption Key in memory. The FVEK can then be used with the help of Dislocker to mount the volume.
このあたりで誰かに任せて寝て起きたら、チームメンバーが実行結果を共有してくれていた。
> python2 ~/tools/volatility/vol.py bitlocker -f memdump.mem --profile=Win10x64_19041 Volatility Foundation Volatility Framework 2.6.1 [FVEK] Address : 0x9e8879926a50 [FVEK] Cipher : AES 128-bit (Win 8+) [FVEK] FVEK: 5b6ff64e4a0ee8f89050b7ba532f6256 [FVEK] Address : 0x9e887496fb30 [FVEK] Cipher : AES 256-bit (Win 8+) [FVEK] FVEK: 60be5ce2a190dfb760bea1ece40e4223c8982aecfd03221a5a43d8fdd302eaee [FVEK] Address : 0x9e8874cb5c70 [FVEK] Cipher : AES 128-bit (Win 8+) [FVEK] FVEK: 1ed2a4b8dd0290f646ded074fbcff8bd [FVEK] Address : 0x9e88779f1a10 [FVEK] Cipher : AES 128-bit (Win 8+) [FVEK] FVEK: bccaf1d4ea09e91f976bf94569761654
DislockerでFVEKをそのまま使う方法が分からなかったのでbdemountを使う。
一番上の5b6ff64e4a0ee8f89050b7ba532f6256がこのbitlockerの鍵だった。
bdemount -k 5b6ff64e4a0ee8f89050b7ba532f6256 /mnt/a/bitlocker-2.dd ~/unlocked/ mount ~/unlocked/bde1 ~/bitlocker_mount/

picoCTF{B1tl0ck3r_dr1v3_d3crypt3d_9029ae5b}