LINE CTF 2022 Writeup

Sun Mar 27 2022

    3/26-27 に開催していた LINE CTF 2022 にチーム WreckTheLine で参加しました。結果は 8th/665 でした。

    去年 web 問が面白かった覚えがあったので web 問に結構力を入れましたが、分からず…結局チームの強い人に解いてもらいました。自分は crypto 全部と各ジャンルの比較的簡単な問題を解きました。久しぶりに生活リズムが崩壊するまで楽しみました。 以下解いた問題についての writeup です。

    crypto

    lazy-STEK

    9 solves

    main.go
    package main
    
    import (
    	"bufio"
    	"crypto/aes"
    	"crypto/rand"
    	"crypto/sha256"
    	"crypto/sha512"
    	"crypto/tls"
    	"fmt"
    	"log"
    	"net"
    
    	"github.com/andreburgaud/crypt2go/ecb"
    )
    
    func main() {
    	var key0 [32]byte
    	var key1 [32]byte
    	var zero_value = [16]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
    
    	// Generate key0
    	_, err := rand.Read(key0[:])
    	if err != nil {
    		fmt.Println("error:", err)
    		return
    	}
    
    	// I'm lazy, so I generate key1 from key0. key1 = SHA256( AES_ECB_ENC(0, SHA512(key0)[16:32]) )
    	tmp_value := sha512.Sum512(key0[:])
    	key0_ticket_encryption_aes_key := tmp_value[16:32]
    
    	block, err := aes.NewCipher(key0_ticket_encryption_aes_key)
    	if err != nil {
    		panic(err)
    	}
    	ecb_mode := ecb.NewECBEncrypter(block)
    	ecb_mode.CryptBlocks(key1[:16], zero_value[:])
    
    	key1 = sha256.Sum256(key1[:16])
    
    	cert, err := tls.LoadX509KeyPair("./secret/cert.pem", "./secret/key.pem")
    
    	if err != nil {
    		log.Fatal(err)
    	}
    	cfg := &tls.Config{
    		Certificates: []tls.Certificate{cert},
    	}
    	cfg.SetSessionTicketKeys([][32]byte{
    		key0,
    		key1,
    	})
    	listener, err := tls.Listen("tcp", ":8000", cfg)
    	if err != nil {
    		log.Fatal(err)
    	}
    	defer listener.Close()
    
    	for {
    		conn, err := listener.Accept()
    		if err != nil {
    			log.Println(err)
    			continue
    		}
    		go handleConnection(conn)
    	}
    
    }
    
    func handleConnection(conn net.Conn) {
    	defer conn.Close()
    	r := bufio.NewReader(conn)
    	for {
    		msg, err := r.ReadString('\n')
    		if err != nil {
    			log.Println(err)
    			return
    		}
    
    		println(msg)
    		n, err := conn.Write([]byte(""))
    		if err != nil {
    			log.Println(n, err)
    			return
    		}
    	}
    }
    gcm_ticket.go
    //This patch is provided under the 3-Clause BSD License, the original license of Golang.
    
    /*
    Copyright (c) 2009 The Go Authors. All rights reserved.
    
    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions are
    met:
    
       * Redistributions of source code must retain the above copyright
    notice, this list of conditions and the following disclaimer.
       * Redistributions in binary form must reproduce the above
    copyright notice, this list of conditions and the following disclaimer
    in the documentation and/or other materials provided with the
    distribution.
       * Neither the name of Google Inc. nor the names of its
    contributors may be used to endorse or promote products derived from
    this software without specific prior written permission.
    
    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
    A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
    */
    
    // This code was created based on src/crypto/tls/ticket.go (L131-L200) in Go1.7.1.
    // original: https://github.com/golang/go/blob/go1.7.1/src/crypto/tls/ticket.go#L132-L200
    // If you want to experiment with this patch, please use a virtual environment that you can throw away, such as Docker.
    // e.g. https://hub.docker.com/layers/golang/library/golang/1.17.1/images/sha256-8f4773d3be4e83da2198ae437191f8d9dffb30507714f0b727d0daf222377886
    
    const FLAG string = "LINECTF{...}"
    
    func (c *Conn) encryptTicket(state []byte) ([]byte, error) {
    	fmt.Println("called custom encryptTicket function!")
    	if len(c.ticketKeys) == 0 {
    		return nil, errors.New("tls: internal error: session ticket keys unavailable")
    	}
    
    	// write FLAG in certicicate
    	new_state := make([]byte, len(state)+len([]byte(FLAG)))
    	sessionState := new(sessionStateTLS13)
    	if ok := sessionState.unmarshal(state); !ok {
    		return nil, errors.New("tls: failed to unmarshal ticket data")
    	}
    	sessionState.certificate.Certificate = append(sessionState.certificate.Certificate, []byte(FLAG))
    	new_state = sessionState.marshal()
    
    	// allocate memory
    	encrypted := make([]byte, ticketKeyNameLen+aes.BlockSize+len(new_state)+sha256.Size)
    	keyName := encrypted[:ticketKeyNameLen]
    	iv := make([]byte, aes.BlockSize)
    
    	// Select Session Ticket Encryption Key (STEK).
    	// aesKey is generated with following formula
    	// aesKey = SHA512(STEK)[16:32]
    	// ref: https://github.com/golang/go/blob/go1.17.1/src/crypto/tls/common.go#L758-L764
    	rand.Seed(time.Now().UnixNano())
    	index := rand.Intn(2)
    	key := c.ticketKeys[index]
    
    	copy(keyName, key.keyName[:])
    	block, err := aes.NewCipher(key.aesKey[:])
    	if err != nil {
    		return nil, errors.New("tls: failed to create cipher while encrypting ticket: " + err.Error())
    	}
    
    	// I'm lazy, so I generate IV from aesKey.
    	h := sha256.New()
    	h.Write(key.aesKey[:])
    	iv = h.Sum(nil)[:16]
    	if 0 == index {
    		copy(iv[12:], []byte{0xaa, 0xaa, 0xaa, 0xaa})
    	} else {
    		copy(iv[12:], []byte{0xbb, 0xbb, 0xbb, 0xbb})
    	}
    	copy(encrypted[ticketKeyNameLen:ticketKeyNameLen+aes.BlockSize], iv[:16])
    
    	aesgcm, err := cipher.NewGCM(block)
    	if err != nil {
    		return nil, errors.New("tls: failed to create cipher while GCM session for encrypting ticket: " + err.Error())
    	}
    
    	// AES-128-GCM_ENC(nonce=iv[:12](12-octet), plaintext=raw_ticket, aad=keyName(16-octet)+iv(16-octet))
    	ciphertext := aesgcm.Seal(nil, iv[:12], new_state, encrypted[:ticketKeyNameLen+aes.BlockSize])
    	copy(encrypted[ticketKeyNameLen+aes.BlockSize:], ciphertext)
    	return encrypted, nil
    }
    
    func (c *Conn) decryptTicket(encrypted []byte) (plaintext []byte, usedOldKey bool) {
    	if len(encrypted) < ticketKeyNameLen+aes.BlockSize+sha256.Size {
    		return nil, false
    	}
    	tagsize := 16
    	keyName := encrypted[:ticketKeyNameLen]
    	iv := encrypted[ticketKeyNameLen : ticketKeyNameLen+aes.BlockSize]
    	ciphertext := encrypted[ticketKeyNameLen+aes.BlockSize : len(encrypted)-sha256.Size+tagsize]
    
    	keyIndex := -1
    	for i, candidateKey := range c.ticketKeys {
    		if bytes.Equal(keyName, candidateKey.keyName[:]) {
    			keyIndex = i
    			break
    		}
    	}
    	if keyIndex == -1 {
    		return nil, false
    	}
    	key := &c.ticketKeys[keyIndex]
    
    	block, err := aes.NewCipher(key.aesKey[:])
    	if err != nil {
    		return nil, false
    	}
    
    	aesgcm, err := cipher.NewGCM(block)
    	if err != nil {
    		return nil, false
    	}
    
    	pt, err := aesgcm.Open(nil, iv[:12], ciphertext, encrypted[:ticketKeyNameLen+aes.BlockSize])
    	if err != nil {
    		return nil, false
    	}
    
    	return pt, keyIndex > 0
    }

    上記ファイルと .pcap ファイルが与えられます。フラグは client 側で AES-GCM で暗号化されて送信されています。

    TLS 通信がよくわからんのでどこを見ればいいのかわからなかったのですが、 iv の末尾が aaaaaaaabbbbbbbb となっているはずなのでこれらの文字列を wireshark で探すと、 pre_shared_key -> Pre-Shared Key extension -> PSK Identity というところに暗号化されたもの (gcm_ticket.go 内の encrypted に相当) がありました。全部で3つありました。

    256f6e3b40c2c006f26dbe24b70c6ed6e875cec70f64aac0de67af2caaaaaaaa450abecfee723cdbe4393bbcf56add91e283615eaa6a5899906a138ce3dbe632ab778328029499c12eceefa0589945f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f8793b17261047b160c9acaf891577ef700000000000000000000000000000000
    256f6e3b40c2c006f26dbe24b70c6ed6e875cec70f64aac0de67af2caaaaaaaa450abecfee723cdbe4393bbce26a50c35bd4b250c5395150b62c27d76e20535dea6a129d08c1c31e89475b79d36e45f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f1f668e1af6844a40e4cbdb6132cbd39500000000000000000000000000000000
    ffd08593ad673b9005296a50f603af28c336d16a10aac82969a59560bbbbbbbb6fe550ba6db4b6a2af74f6f0454d82d959daa387f694685dec4c1ff7c36e40d3b9fe6e4fd41596035a594f8b599b89c47c84aa66d6d63ef3999de5041f0c3b7598b1811012399575a0c442c1c364f669ecf7fd5dfbb06bc37fd830c03e3dde20c98bc747d74d0ac196936f364c2e81338fca4bdb193d52e19f23295fc9e7546288a7464baa258fcd554200000000000000000000000000000000
    

    gcm_ticket.go をみると、最初の16bytesが keyName (これは何?)、次の16bytesが iv、そのあとが AES-GCM による暗号・タグとなることがわかります。 go で適当に実験をすると go の AES-GCM は後半16bytesがタグであることがわかります。

    - keyName
    256f6e3b40c2c006f26dbe24b70c6ed6
    256f6e3b40c2c006f26dbe24b70c6ed6
    ffd08593ad673b9005296a50f603af28
    
    - iv
    e875cec70f64aac0de67af2caaaaaaaa
    e875cec70f64aac0de67af2caaaaaaaa
    c336d16a10aac82969a59560bbbbbbbb
    
    - cipher text
    450abecfee723cdbe4393bbcf56add91e283615eaa6a5899906a138ce3dbe632ab778328029499c12eceefa0589945f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f
    450abecfee723cdbe4393bbce26a50c35bd4b250c5395150b62c27d76e20535dea6a129d08c1c31e89475b79d36e45f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f
    6fe550ba6db4b6a2af74f6f0454d82d959daa387f694685dec4c1ff7c36e40d3b9fe6e4fd41596035a594f8b599b89c47c84aa66d6d63ef3999de5041f0c3b7598b1811012399575a0c442c1c364f669ecf7fd5dfbb06bc37fd830c03e3dde20c98bc747d74d0ac196936f364c2e81338fca4bdb193d52e19f23
    
    - tag
    8793b17261047b160c9acaf891577ef7
    1f668e1af6844a40e4cbdb6132cbd395
    295fc9e7546288a7464baa258fcd5542
    

    整理してみると iv が同じものが存在しています。 AES-GCM では nonce の使いまわしをすることで署名計算に使われる H=AESK(0)H = \mathrm{AES}_K(0) を計算できてしまうことが知られています (例えば こちらの記事こちらの writeup が参考になります)。なのでまずはこれを求めます。

    from binascii import unhexlify
    from hashlib import sha256, sha512
    
    from Crypto.Cipher import AES
    from Crypto.Util.number import bytes_to_long, long_to_bytes
    
    
    X = GF(2).polynomial_ring().gen()
    poly = X ** 128 + X ** 7 + X ** 2 + X ** 1 + 1
    F = GF(2 ** 128, name='a', modulus=poly)
    R.<x> = PolynomialRing(F)
    
    
    def tobin(x, n):
        x = Integer(x)
        nbits = x.nbits()
        assert nbits <= n
        return x.bits() + [0] * (n - nbits)
    
    def frombin(v):
        return int("".join(map(str, v)), 2)
    
    def toF(x):
        x = frombin(tobin(x, 128))
        return F.fetch_int(x)
    
    def fromF(x):
        x = x.integer_representation()
        x = frombin(tobin(x, 128))
        return x
    
    
    key_name = unhexlify("256f6e3b40c2c006f26dbe24b70c6ed6")
    iv       = unhexlify("e875cec70f64aac0de67af2caaaaaaaa")
    ct0 = unhexlify("450abecfee723cdbe4393bbcf56add91e283615eaa6a5899906a138ce3dbe632ab778328029499c12eceefa0589945f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f")
    ct1 = unhexlify("450abecfee723cdbe4393bbce26a50c35bd4b250c5395150b62c27d76e20535dea6a129d08c1c31e89475b79d36e45f7f3801748be3daa06ace2e682a77649da535f7235aa7ecb60bf0e3d6b7c1012e192411e29e6494c2fa05ce2c5d08d4698a05ffb5fa9ad2b2550737cea3b19ccacfdd93e7d3c3f6e641d5f")
    
    tag0 = toF(bytes_to_long(unhexlify("8793b17261047b160c9acaf891577ef7")))
    tag1 = toF(bytes_to_long(unhexlify("1f668e1af6844a40e4cbdb6132cbd395")))
    
    
    Cs0 = []
    Cs1 = []
    Cs0.append(toF(bytes_to_long(key_name)))
    Cs0.append(toF(bytes_to_long(iv)))
    Cs1.append(toF(bytes_to_long(key_name)))
    Cs1.append(toF(bytes_to_long(iv)))
    for i in range(0, len(ct0), 16):
        Cs0.append(toF(bytes_to_long(ct0[i: i+16].ljust(16, b"\x00"))))
        Cs1.append(toF(bytes_to_long(ct1[i: i+16].ljust(16, b"\x00"))))
    
    Cs01 = [Cs0[i] + Cs1[i] for i in range(len(Cs0))]
    
    PR.<x> = PolynomialRing(F)
    f = Cs01[2] * x**9 + Cs01[3] * x**8 + Cs01[4] * x**7 + (tag0 + tag1)
    roots = f.roots()

    最後の roots のどちらかが HH に相当します。

    とりあえず HH を求めましたがこれをどう使うのでしょうか。問題ソースコード中のコメントに注目すると、 server 側の key0, key1 を使って AES の key が計算されていること、 key1key0 を使って定義されていることがわかります。特に key1 は client 側の AES key を使って AES(0) から計算されており、先程求めた HH から計算可能です。

    for root in roots:
        H = root[0]
        enc_0 = long_to_bytes(fromF(H))
        key1 = sha256(enc_0).digest()
        aes_key = sha512(key1).digest()[16:32]
        if sha256(aes_key).digest()[:12].hex() == "c336d16a10aac82969a59560":
            print("found")
            break

    これで key1 から求まるほうの AES の key がわかりました (iv の末尾が bbbbbbbb のほう)。これを使って復号を試みます。

    cipher = AES.new(aes_key, mode=AES.MODE_ECB)
    iv = unhexlify("c336d16a10aac82969a59560")
    ct = unhexlify("6fe550ba6db4b6a2af74f6f0454d82d959daa387f694685dec4c1ff7c36e40d3b9fe6e4fd41596035a594f8b599b89c47c84aa66d6d63ef3999de5041f0c3b7598b1811012399575a0c442c1c364f669ecf7fd5dfbb06bc37fd830c03e3dde20c98bc747d74d0ac196936f364c2e81338fca4bdb193d52e19f23")
    pt = b""
    for i in range(0, len(ct), 16):
        ct_block = ct[i: i+16]
        D = toF(bytes_to_long(ct_block.ljust(16, b"\x00")))
        C = D + toF(bytes_to_long(cipher.encrypt(iv + int(i//16+2).to_bytes(4, "big"))))
        pt_block = long_to_bytes(fromF(C))[:len(ct_block)]
        pt += pt_block
    print(pt)

    LINECTF{N0nc3_r3us3_je0p4rd1ze_Hk3y_that_is_us3d_f0r_auth3nt1cat1on}

    この問題は深夜 (というか早朝) に解いたのですが、眠さの限界のせいか server 側の key から client 側の AES key を求めるところでバグらせまくって死んでました。 key1 の長さを16bytesと勘違いしていたり、sha256をもう一回かけなきゃいけないのを忘れていたり、 AES-GCM に関係ないところで… 問題自体はコメントが丁寧に書かれていて親切だったと思います (.pcap にする必要があったのかは不明)。

    Forward-or

    43 solves

    main.py
    from present import Present
    from Crypto.Util.strxor import strxor
    import os, re
    
    class CTRMode():
        def __init__(self, key, nonce=None):
            self.key = key # 20bytes
            self.cipher = DoubleRoundReducedPresent(key)
            if None==nonce:
                nonce = os.urandom(self.cipher.block_size//2)
            self.nonce = nonce # 4bytes
        
        def XorStream(self, data):
            output = b""
            counter = 0
            for i in range(0, len(data), self.cipher.block_size):
                keystream = self.cipher.encrypt(self.nonce+counter.to_bytes(self.cipher.block_size//2, 'big'))
                if b""==keystream:
                    exit(1)
    
                if len(data)<i+self.cipher.block_size:
                    block = data[i:len(data)]
                block = data[i:i+self.cipher.block_size]
                block = strxor(keystream[:len(block)], block)
                
                output+=block
                counter+=1
            return output
    
        def encrypt(self, plaintext):
            return self.XorStream(plaintext)
    
        def decrypt(self, ciphertext):
            return self.XorStream(ciphertext)
    
    class DoubleRoundReducedPresent():
    
        def __init__(self, key):
            self.block_size = 8
            self.key_length = 160 # bits
            self.round = 16
            self.cipher0 = Present(key[0:10], self.round)
            self.cipher1 = Present(key[10:20], self.round)
        
        def encrypt(self, plaintext):
            if len(plaintext)>self.block_size:
                print("Error: Plaintext must be less than %d bytes per block" % self.block_size)
                return b""
            return self.cipher1.encrypt(self.cipher0.encrypt(plaintext))
        
        def decrypt(self, ciphertext):
            if len(ciphertext)>self.block_size:
                print("Error: Ciphertext must be less than %d bytes per block" % self.block_size)
                return b""
            return self.cipher0.decrypt(self.cipher1.decrypt(ciphertext))
    
    if __name__ == "__main__":
        import sys, os
        sys.path.append(os.path.join(os.path.dirname(__file__), './secret/'))
        from keyfile import key
        from flag import flag
    
        # load key
        if not re.fullmatch(r'[0-3]+', key):
            exit(1)
        key = key.encode('ascii')
    
        # load flag
        flag = flag.encode('ascii') # LINECTF{...}
    
        plain = flag
        cipher = CTRMode(key)
        ciphertext = cipher.encrypt(plain)
        nonce = cipher.nonce
    
        print(ciphertext.hex())
        print(nonce.hex())
    present.py
    '''
    Python3 PRESENT implementation
    
    original code (implementation for python2) is here:
    http://www.lightweightcrypto.org/downloads/implementations/pypresent.py
    '''
    
    
    '''
    key = bytes.fromhex("00000000000000000000")
    plain = bytes.fromhex("0000000000000000")
    cipher = Present(key)
    encrypted = cipher.encrypt(plain)
    print(encrypted.hex())
    >> '5579c1387b228445'
    decrypted = cipher.decrypt(encrypted)
    decrypted.hex()
    >> '0000000000000000'
    '''
    
    class Present:
    
        def __init__(self,key,rounds=32):
            """Create a PRESENT cipher object
    
            key:    the key as a 128-bit or 80-bit rawstring
            rounds: the number of rounds as an integer, 32 by default
            """
            self.rounds = rounds
            if len(key) * 8 == 80:
                self.roundkeys = generateRoundkeys80(byte2number(key),self.rounds)
            elif len(key) * 8 == 128:
                self.roundkeys = generateRoundkeys128(byte2number(key),self.rounds)
            else:
                raise (ValueError, "Key must be a 128-bit or 80-bit rawstring")
    
        def encrypt(self,block):
            """Encrypt 1 block (8 bytes)
    
            Input:  plaintext block as raw string
            Output: ciphertext block as raw string
            """
            state = byte2number(block)
            for i in range(self.rounds-1):
                state = addRoundKey(state,self.roundkeys[i])
                state = sBoxLayer(state)
                state = pLayer(state)
            cipher = addRoundKey(state,self.roundkeys[-1])
            return number2byte_N(cipher,8)
    
        def decrypt(self,block):
            """Decrypt 1 block (8 bytes)
    
            Input:  ciphertext block as raw string
            Output: plaintext block as raw string
            """
            state = byte2number(block)
            for i in range(self.rounds-1):
                state = addRoundKey(state,self.roundkeys[-i-1])
                state = pLayer_dec(state)
                state = sBoxLayer_dec(state)
            decipher = addRoundKey(state,self.roundkeys[0])
            return number2byte_N(decipher,8)
    
        def get_block_size(self):
            return 8
    
    #    0   1   2   3   4   5   6   7   8   9   a   b   c   d   e   f
    Sbox= [0xc,0x5,0x6,0xb,0x9,0x0,0xa,0xd,0x3,0xe,0xf,0x8,0x4,0x7,0x1,0x2]
    Sbox_inv = [Sbox.index(x) for x in range(16)]
    PBox = [0,16,32,48,1,17,33,49,2,18,34,50,3,19,35,51,
        4,20,36,52,5,21,37,53,6,22,38,54,7,23,39,55,
        8,24,40,56,9,25,41,57,10,26,42,58,11,27,43,59,
        12,28,44,60,13,29,45,61,14,30,46,62,15,31,47,63]
    PBox_inv = [PBox.index(x) for x in range(64)]
    
    def generateRoundkeys80(key,rounds):
        """Generate the roundkeys for a 80-bit key
    
        Input:
            key:    the key as a 80-bit integer
            rounds: the number of rounds as an integer
        Output: list of 64-bit roundkeys as integers"""
        roundkeys = []
        for i in range(1,rounds+1): # (K1 ... K32)
            # rawkey: used in comments to show what happens at bitlevel
            # rawKey[0:64]
            roundkeys.append(key >>16)
            #1. Shift
            #rawKey[19:len(rawKey)]+rawKey[0:19]
            key = ((key & (2**19-1)) << 61) + (key >> 19)
            #2. SBox
            #rawKey[76:80] = S(rawKey[76:80])
            key = (Sbox[key >> 76] << 76)+(key & (2**76-1))
            #3. Salt
            #rawKey[15:20] ^ i
            key ^= i << 15
        return roundkeys
    
    def generateRoundkeys128(key,rounds):
        """Generate the roundkeys for a 128-bit key
    
        Input:
            key:    the key as a 128-bit integer
            rounds: the number of rounds as an integer
        Output: list of 64-bit roundkeys as integers"""
        roundkeys = []
        for i in range(1,rounds+1): # (K1 ... K32)
            # rawkey: used in comments to show what happens at bitlevel
            roundkeys.append(key >>64)
            #1. Shift
            key = ((key & (2**67-1)) << 61) + (key >> 67)
            #2. SBox
            key = (Sbox[key >> 124] << 124)+(Sbox[(key >> 120) & 0xF] << 120)+(key & (2**120-1))
            #3. Salt
            #rawKey[62:67] ^ i
            key ^= i << 62
        return roundkeys
    
    def addRoundKey(state,roundkey):
        return state ^ roundkey
    
    def sBoxLayer(state):
        """SBox function for encryption
    
        Input:  64-bit integer
        Output: 64-bit integer"""
    
        output = 0
        for i in range(16):
            output += Sbox[( state >> (i*4)) & 0xF] << (i*4)
        return output
    
    def sBoxLayer_dec(state):
        """Inverse SBox function for decryption
    
        Input:  64-bit integer
        Output: 64-bit integer"""
        output = 0
        for i in range(16):
            output += Sbox_inv[( state >> (i*4)) & 0xF] << (i*4)
        return output
    
    def pLayer(state):
        """Permutation layer for encryption
    
        Input:  64-bit integer
        Output: 64-bit integer"""
        output = 0
        for i in range(64):
            output += ((state >> i) & 0x01) << PBox[i]
        return output
    
    def pLayer_dec(state):
        """Permutation layer for decryption
    
        Input:  64-bit integer
        Output: 64-bit integer"""
        output = 0
        for i in range(64):
            output += ((state >> i) & 0x01) << PBox_inv[i]
        return output
    
    def byte2number(i):
        """ Convert a string to a number
    
        Input: byte (big-endian)
        Output: long or integer
        """
        return int.from_bytes(i, 'big')
    
    def number2byte_N(i, N):
        """Convert a number to a string of fixed size
    
        i: long or integer
        N: length of byte
        Output: string (big-endian)
        """
        return i.to_bytes(N, byteorder='big')
    
    def _test():
        import doctest
        doctest.testmod()
    
    if __name__ == "__main__":
        _test()

    key[0-3]{20} という形式になっており、 DoubleRoundReducedPresent は前半10bytesの key で Present.encrypt 、さらに後半10bytes の key で Present.encrypt を行います。

    key の探索空間の広さがぱっとみだと 4204^{20} です。しかし以下の方法で 4104^{10} まで計算量を落とすことができ、これは十分計算可能な量となります。 nonce が既知なので10bytesの key の全パターンで Present.encrypt(nonce) をした結果を列挙できます。さらに平文の先頭8bytesが LINECTF{ であることを使って DoubleRoundReducedPresent.encrypt の最初のブロック結果がわかっています。そこで DoubleRoundReducedPresent.encrypt の結果をある10bytesの key で Present.decrypt した結果が、先程全列挙した Present.encrypt(nonce) に存在していれば、そのときの key は実際に使われた key ということになります。

    この方法で key を特定し、復号してフラグを得ました。

    solve.py
    from binascii import unhexlify
    from itertools import product
    
    from tqdm import tqdm
    
    
    ciphertext = unhexlify("3201339d0fcffbd152f169ddcb8349647d8bc36a73abc4d981d3206f4b1d98468995b9b1c15dc0f0")
    nonce = unhexlify("32e10325")
    
    inp = nonce + (0).to_bytes(4, 'big')
    encs0 = {}
    for key_tuple in tqdm(product("0123", repeat=10), total=4**10):
        key = ("".join(key_tuple)).encode()
        cipher = Present(key, 16)
        encs0[cipher.encrypt(inp)] = key
    
    out = strxor(b"LINECTF{", ciphertext[:8])
    for key_tuple in tqdm(product("0123", repeat=10), total=4**10):
        key = ("".join(key_tuple)).encode()
        cipher = Present(key, 16)
        tmp = cipher.decrypt(out)
        if tmp in encs0:
            key1 = key
            key0 = encs0[tmp]
            key = key0 + key1
            print(key)
            break
    
    cipher = CTRMode(key, nonce=nonce)
    cipher.decrypt(ciphertext)

    LINECTF{|->TH3Y_m3t_UP_1n_th3_m1ddl3<-|}

    Baby crypto revisited

    48 solves

    ECDSA の r,s,k,hr, s, k, h のペアが複数与えられています。

    典型的な HNP だし、この形式どこかで見たなと思ったら、去年の LINE CTF でほぼ同じ問題が出ていました。去年は kk の値が小さすぎて上位ビットの情報を使わなくても解けてしまったのですが、今年はちょっとビット数が増えており上位ビットを使う必要がありそうです。それだけの差分なので去年の script をコピペし、上位ビットの情報を取り入れるだけで解けました。これは…

    solve.sage
    from binascii import unhexlify
    from Crypto.Util.number import long_to_bytes, bytes_to_long
    import re
    with open("./Babycrypto_revisited_b1f108dea290b83253b80443260b12c3cadc0ed7.txt") as f:
        buf = f.read()
    rs = []
    ss = []
    ks = []
    hs = []
    for r, s, k, h in re.findall(r"(0x[0-9a-f]*) (0x[0-9a-f]*) (0x[0-9a-f]*) (0x[0-9a-f]*)", buf):
        rs.append(int(r, 16))
        ss.append(int(s, 16))
        ks.append(int(k, 16))
        tmp_h = h[2:]
        if len(tmp_h) % 2 == 1:
            tmp_h = "0" + tmp_h
        hs.append(bytes_to_long(unhexlify(tmp_h)))
    
    
    # https://neuromancer.sk/std/secg/secp160r1
    p = 0xffffffffffffffffffffffffffffffff7fffffff
    a = 0xffffffffffffffffffffffffffffffff7ffffffc
    b = 0x1c97befc54bd7a8b65acf89f81d4d4adc565fa45
    EC = EllipticCurve(GF(p), [a, b])
    G = EC(0x4a96b5688ef573284664698968c38bb913cbfc82, 0x23a628553168947d59dcc912042351377ac5fb32)
    n = 0x0100000000000000000001f4c8f927aed3ca752257
    h = 0x1
    
    m = len(rs)
    M = matrix(QQ, m+2, m+2)
    order = n
    B = 2**150
    for i in range(m):
        M[i, i] = QQ(order)
        M[m, i] = QQ(pow(ss[i], -1, order) * rs[i])
        M[m+1, i] = -QQ(pow(ss[i], -1, order) * hs[i] - ks[i])
    M[m, m] = QQ(B) / QQ(order)
    M[m+1, m+1] = QQ(B)
    
    C = M.LLL()
    
    for i in range(m+2):
        if abs(C[i][-1]) != B:
            continue
        d = ZZ(C[i][-2] * order / B)
        if C[i][-1] < 0:
            d = -d
        for j in range(m):
            kl = (int(pow(ss[j], -1, order) * rs[j] * int(d)) + int(pow(ss[j], -1, order) * hs[j])) % order
            print(kl, ks[j])
    print(hex(d))
    # or order - d

    LINECTF{0xd77d10fec685cbe16f64cba090db24d23b92f824}

    去年の自分の writeup のアクセス数が増えてて、それはそうという気持ちになりました。現場からは以上です。

    X Factor

    102 solves

    比較的小さい数字と、それを RSA で署名したもののペアがいくつか与えられています。

    単純には解けないのですが、与えられた数字を素因数分解してみると、似たような素数の積で表されていることがわかります。存在する素数は9種類で署名のペアは7種類あります。これらを使って線形に求めます。

    solve.sage
    n = 0xa9e7da28ebecf1f88efe012b8502122d70b167bdcfa11fd24429c23f27f55ee2cc3dcd7f337d0e630985152e114830423bfaf83f4f15d2d05826bf511c343c1b13bef744ff2232fb91416484be4e130a007a9b432225c5ead5a1faf02fa1b1b53d1adc6e62236c798f76695bb59f737d2701fe42f1fbf57385c29de12e79c5b3
    mcs = [
        (0x945d86b04b2e7c7, 0x17bb21949d5a0f590c6126e26dc830b51d52b8d0eb4f2b69494a9f9a637edb1061bec153f0c1d9dd55b1ad0fd4d58c46e2df51d293cdaaf1f74d5eb2f230568304eebb327e30879163790f3f860ca2da53ee0c60c5e1b2c3964dbcf194c27697a830a88d53b6e0ae29c616e4f9826ec91f7d390fb42409593e1815dbe48f7ed4),
        (0x5de2, 0x3ea73715787028b52796061fb887a7d36fb1ba1f9734e9fd6cb6188e087da5bfc26c4bfe1b4f0cbfa0d693d4ac0494efa58888e8415964c124f7ef293a8ee2bc403cad6e9a201cdd442c102b30009a3b63fa61cdd7b31ce9da03507901b49a654e4bb2b03979aea0fab3731d4e564c3c30c75aa1d079594723b60248d9bdde50),
        (0xa16b201cdd42ad70da249, 0x9444e3fc71056d25489e5ce78c6c986c029f12b61f4f4b5cbd4a0ce6b999919d12c8872b8f2a8a7e91bd0f263a4ead8f2aa4f7e9fdb9096c2ea11f693f6aa73d6b9d5e351617d6f95849f9c73edabd6a6fde6cc2e4559e67b0e4a2ea8d6897b32675be6fc72a6172fd42a8a8e96adfc2b899015b73ff80d09c35909be0a6e13a),
        (0x6d993121ed46b, 0x2b7a1c4a1a9e9f9179ab7b05dd9e0089695f895864b52c73bfbc37af3008e5c187518b56b9e819cc2f9dfdffdfb86b7cc44222b66d3ea49db72c72eb50377c8e6eb6f6cbf62efab760e4a697cbfdcdc47d1adc183cc790d2e86490da0705717e5908ad1af85c58c9429e15ea7c83ccf7d86048571d50bd721e5b3a0912bed7c),
        (0x726fa7a7, 0xa7d5548d5e4339176a54ae1b3832d328e7c512be5252dabd05afa28cd92c7932b7d1c582dc26a0ce4f06b1e96814ee362ed475ddaf30dd37af0022441b36f08ec8c7c4135d6174167a43fa34f587abf806a4820e4f74708624518044f272e3e1215404e65b0219d42a706e5c295b9bf0ee8b7b7f9b6a75d76be64cf7c27dfaeb),
        (0x31e828d97a0874cff, 0x67832c41a913bcc79631780088784e46402a0a0820826e648d84f9cc14ac99f7d8c10cf48a6774388daabcc0546d4e1e8e345ee7fc60b249d95d953ad4d923ca3ac96492ba71c9085d40753cab256948d61aeee96e0fe6c9a0134b807734a32f26430b325df7b6c9f8ba445e7152c2bf86b4dfd4293a53a8d6f003bf8cf5dffd),
        (0x904a515, 0x927a6ecd74bb7c7829741d290bc4a1fd844fa384ae3503b487ed51dbf9f79308bb11238f2ac389f8290e5bcebb0a4b9e09eda084f27add7b1995eeda57eb043deee72bfef97c3f90171b7b91785c2629ac9c31cbdcb25d081b8a1abc4d98c4a1fd9f074b583b5298b2b6cc38ca0832c2174c96f2c629afe74949d97918cbee4a),
    ]
    
    ms, cs = zip(*mcs)
    
    
    set(sum([[p for p, _ in factor(ms[i])] for i in range(7)], []))
    
    ps = [2, 61, 197, 811, 947, 970111, 2098711, 2854343, 9605087]
    target = 0x686178656c696f6e
    # target == 2 * 197 * 947 * 2098711 * 9605087
    target_coeff = [1, 0, 1, 0, 1, 0, 1, 0, 1]
    coeffs = []
    for i in range(7):
        m = ms[i]
        pes = factor(m)
        coeff = [0] * len(ps)
        for p, e in pes:
            coeff[ps.index(p)] = e
        coeffs.append(coeff)
    
    M = matrix(coeffs)
    t = vector(target_coeff)
    c = M.solve_left(t)
    
    sign = 1
    for i in range(len(c)):
        sign *= pow(cs[i], c[i], n)
    print(sign)

    LINECTF{a049347a7db8226d496eb55c15b1d840}

    ss-puzzle

    221 solves

    ss_puzzle.py
    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    
    # 64 bytes
    FLAG = b'LINECTF{...}'
    
    def xor(a:bytes, b:bytes) -> bytes:
        return bytes(i^j for i, j in zip(a, b))
    
    
    S = [None]*4
    R = [None]*4
    Share = [None]*5
    
    S[0] = FLAG[0:8]
    S[1] = FLAG[8:16]
    S[2] = FLAG[16:24]
    S[3] = FLAG[24:32]
    
    # Ideally, R should be random stream. (Not hint)
    R[0] = FLAG[32:40]
    R[1] = FLAG[40:48]
    R[2] = FLAG[48:56]
    R[3] = FLAG[56:64]
    
    Share[0] = R[0]            + xor(R[1], S[3]) + xor(R[2], S[2]) + xor(R[3],S[1])
    Share[1] = xor(R[0], S[0]) + R[1]            + xor(R[2], S[3]) + xor(R[3],S[2])
    Share[2] = xor(R[0], S[1]) + xor(R[1], S[0]) + R[2]            + xor(R[3],S[3])
    Share[3] = xor(R[0], S[2]) + xor(R[1], S[1]) + xor(R[2], S[0]) + R[3]
    Share[4] = xor(R[0], S[3]) + xor(R[1], S[2]) + xor(R[2], S[1]) + xor(R[3],S[0])
    
    
    # This share is partially broken.
    Share[1] = Share[1][0:8]   + b'\x00'*8       + Share[1][16:24] + Share[1][24:32]
    
    with open('./Share1', 'wb') as f:
        f.write(Share[1])
        f.close()
    
    with open('./Share4', 'wb') as f:
        f.write(Share[4])
        f.close()

    xor でガチャガチャやっています。フラグの先頭は LINECTF{ なことと Share1, Share4 の値からすべての値が求まります。

    solve.py
    from pwn import xor
    
    with open("./Share1", "rb") as fp:
        share_1 = fp.read()
    with open("./Share4", "rb") as fp:
        share_4 = fp.read()
    
    
    def xor(a:bytes, b:bytes) -> bytes:
        return bytes(i^^j for i, j in zip(a, b))
    
    
    flag_0 = b"LINECTF{"
    flag_3 = xor(share_1[:8], share_4[:8], flag_0)
    flag_1 = xor(share_1[16:24], share_4[16:24], flag_3)
    flag_2 = xor(share_1[24:32], share_4[24:32], flag_0)
    flag_4 = xor(share_4[:8], flag_3)
    flag_5 = xor(share_4[8:16], flag_2)
    flag_6 = xor(share_4[16:24], flag_1)
    flag_7 = xor(share_4[24:32], flag_0)
    
    flag = flag_0 + flag_1 + flag_2 + flag_3 + flag_4 + flag_5 + flag_6 + flag_7

    LINECTF{Yeah_known_plaintext_is_important_in_xor_based_puzzle!!}

    Web

    Memo Drive

    42 solves

    index.py
    import os
    import hashlib
    import shutil
    import datetime
    import uvicorn
    import logging
    
    from urllib.parse import unquote
    from starlette.applications import Starlette
    from starlette.responses import HTMLResponse
    from starlette.routing import Route, Mount
    from starlette.templating import Jinja2Templates
    from starlette.staticfiles import StaticFiles
    
    logger = logging.getLogger()
    logger.setLevel(logging.DEBUG)
    
    templates = Jinja2Templates(directory='./')
    templates.env.autoescape = False
    
    def index(request):
        context = {}
        memoList = []
        
        try:
            clientId = getClientID(request.client.host)
            path = './memo/' + clientId
            
            if os.path.exists(path):
                memoList = os.listdir(path)
            
            context['request'] = request
            context['ip'] = request.client.host
            context['clientId'] = clientId
            context['memoList'] = memoList
            context['count'] = len(memoList)
        
        except:
            pass
        
        return templates.TemplateResponse('/view/index.html', context)
    
    def save(request):
        context = {}
        memoList = []
        
        try:
            context['request'] = request
            context['ip'] = request.client.host
            
            contents = request.query_params['contents']
            path = './memo/' + getClientID(request.client.host) + '/'
            
            if os.path.exists(path) == False:
                os.makedirs(path, exist_ok=True)
            
            memoList = os.listdir(path)
            idx = len(memoList)
            
            if idx >= 3:
                return HTMLResponse('Memo Full')
            elif len(contents) > 100:
                return HTMLResponse('Contents Size Error (MAX:100)')
            
            filename = str(idx) + '_' + datetime.datetime.now().strftime('%Y%m%d%H%M%S')
                
            f = open(path + filename, 'w')
            f.write(contents)
            f.close()
        
        except:
            pass
        
        return HTMLResponse('Save Complete')
    
    def reset(request):
        context = {}
        
        try:
            context['request'] = request
                
            clientId = getClientID(request.client.host)
            path = './memo/' + clientId
                
            if os.path.exists(path) == False:
                return HTMLResponse('Memo Null')
            
            shutil.rmtree(path)
                
        except:
            pass
        
        return HTMLResponse('Reset Complete')
    
    def view(request):
        context = {}
    
        try:
            context['request'] = request
            print(request)
            clientId = getClientID(request.client.host)
    
            print(f"{request.url.query = }")
            print(f"{request.query_params[clientId] = }")
            print("".join(request.query_params.keys()))
            if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
                raise
            
            filename = request.query_params[clientId]
            print(f"{filename = }")
            path = './memo/' + "".join(request.query_params.keys()) + '/' + filename
            
            f = open(path, 'r')
            contents = f.readlines()
            f.close()
            
            context['filename'] = filename
            context['contents'] = contents
        
        except:
            pass
        
        return templates.TemplateResponse('/view/view.html', context)
    
    def getClientID(ip):
        key = ip + '_' + os.getenv('SALT')
        
        return hashlib.md5(key.encode('utf-8')).hexdigest()
    
    routes = [
        Route('/', endpoint=index),
        Route('/view', endpoint=view),
        Route('/reset', endpoint=reset),
        Route('/save', endpoint=save),
        Mount('/static', StaticFiles(directory='./static'), name='static')
    ]
    
    app = Starlette(debug=False, routes=routes)
    
    if __name__ == "__main__":
        logging.info("Starting Starlette Server")
        uvicorn.run(app, host="0.0.0.0", port=11000)

    別ディレクトリに格納されている flag ファイルを読み出す問題です。

    明らかに view 関数の処理が怪しい ("".join(requestquery_params.keys()) とかする必要ある?? unquote も一部にしか使われてない) ので、そこに注目しました。

    def view(request):
        context = {}
    
        try:
            context['request'] = request
            print(request)
            clientId = getClientID(request.client.host)
    
            print(f"{request.url.query = }")
            print(f"{request.query_params[clientId] = }")
            print("".join(request.query_params.keys()))
            if '&' in request.url.query or '.' in request.url.query or '.' in unquote(request.query_params[clientId]):
                raise
            
            filename = request.query_params[clientId]
            print(f"{filename = }")
            path = './memo/' + "".join(request.query_params.keys()) + '/' + filename
            
            f = open(path, 'r')
            contents = f.readlines()
            f.close()
            
            context['filename'] = filename
            context['contents'] = contents
        
        except:
            pass
        
        return templates.TemplateResponse('/view/view.html', context)

    いろいろな query を試しましたが、上手くいかず… library 特有の問題だったりするのかな?と思い、 starlette の github issue を探しました。すると https://github.com/encode/starlette/issues/1325 が見つかりました。 ; が query の区切り文字 (& の代わり) になってしまうというバグ報告です。 https://python-security.readthedocs.io/vuln/urllib-query-string-semicolon-separator.html を見ると python の一部のバージョンでこのバグが発現するみたいです。 Dockerfile を見ると python のバージョンは3.9.0であり、これもバグのあるバージョンの一つです。 これを利用し、 /view?CLIENT_ID=flag;/%2e%2e/ のような query を投げると、 CLIENT_ID/../flag のファイルを読み込むことができます。

    LINECTF{The_old_bug_on_urllib_parse_qsl_fixed}

    Pwn

    ecrypt

    105 solves

    root 権限の flag ファイルを読む問題。 fix 問が出てるしやたら solves 数が多いのでなにか抜け穴があるのだろうと思いあれこれ試すと、 su root が通りました。

    LINECTF{WOW!_powerful_kernel_oor_oow}

    Rev

    rolling

    19 solves

    apk ファイルが与えられます。ひとまず apktool d rolling.apk をします。

    smali_classes2/me/linectf/app/MainActivity.smali を見ると、入力が IINECFT{youtube.com/watch?v=dQw4w9WgXcQ} と一致しているか確認しているように見えます。フラグの形式違うけど…?と思いながら submit するも当然弾かれました。

    上記ファイルをもう少し注意深く見ると、 lib/arm64-v8a/libnative-lib.so を読み込んで、その中でフラグを check しているようです。というわけで .so ファイルの reversing をします。

    deep 関数内でフラグチェックを行っています。 meatbox, soulbox, godbox で各文字を計算した結果がメモリ上の値と一致しているかをチェックしています。メモリ上の値をとりあえず持ってきます。

    # 各列は meatbox, soulbox, godbox, 各行は各文字を表す
    [
        ["07", "18", "10"],
        ["0f", "1c", "12"],
        ["05", "0a", "07"],
        ["0b", "02", "0f"],
        ["12", "06", "08"],
        ["13", "0a", "07"],
        ["05", "09", "0b"],
        ["06", "0f", "0f"],
        ["11", "04", "13"],
        ["13", "01", "0e"],
        ["03", "0b", "00"],
        ["01", "01", "09"],
        ["09", "02", "08"],
        ["13", "01", "0e"],
        ["01", "01", "0c"],
        ["09", "05", "10"],
        ["01", "12", "0a"],
        ["08", "0b", "12"],
        ["11", "04", "13"],
        ["01", "01", "0c"],
        ["13", "01", "0e"],
        ["12", "00", "0e"],
        ["08", "0b", "12"],
        ["01", "0f", "0b"],
        ["03", "0b", "00"],
        ["01", "01", "0c"],
        ["07", "05", "04"],
        ["08", "0b", "12"],
        ["08", "18", "0f"],
        ["08", "18", "0f"],
        ["0e", "1c", "0f"],
        ["01", "12", "0a"],
        ["10", "15", "11"],
        ["01", "01", "0c"],
        ["06", "16", "0a"],
        ["08", "0b", "12"],
        ["11", "04", "13"],
        ["01", "12", "0a"],
        ["01", "01", "0c"],
        ["0e", "1c", "0f"],
        ["01", "12", "0a"],
        ["01", "01", "0c"],
        ["03", "0b", "00"],
        ["09", "02", "08"],
        ["04", "0d", "10"],
        ["01", "01", "0c"],
        ["06", "16", "0a"],
        ["04", "0d", "10"],
        ["04", "0d", "10"],
        ["11", "0f", "05"],
        ["07", "17", "02"]
    ]
    

    meatbox とかは既知の何かなのかと思ってググりましたが、特に何も見つからず… (ゲームが元ネタっぽい?) 動作を追うのはとても大変だったので、 aarch64 を qemu とかで実行してその上で gdb でデバッグしようと考えました。しかしクロスコンパイルが上手くいかず、環境準備なども含め5時間ぐらい溶かしてしまいました… (誰かやり方わかる人いたら教えてください、全力で募集しています)

    あまりに時間が溶けて自暴自棄になり、上の暗号化結果だけから平文を求められないかを試しました。例えば頻度を見ると ["01", "01", "0c"] がたくさんあるため、これは _ になりそう。 ABCCDEF (同じアルファベットは同じ文字が入ることを表す) という単語は問題文中にある rolling という文字になりそう。2文字、3文字と続くところは in the とかになりそう。といった感じです。英語として存在する言い回しがあるかなどを検索しながら頑張ると、フラグがある程度求まりました。

    encs = [
        L ["07", "18", "10"],
        I ["0f", "1c", "12"],
        N ["05", "0a", "07"],
        E ["0b", "02", "0f"],
        C ["12", "06", "08"],
        T ["13", "0a", "07"],
        F ["05", "09", "0b"],
        { ["06", "0f", "0f"],
        w ["11", "04", "13"],
        a ["13", "01", "0e"],
        t ["03", "0b", "00"],
        c ["01", "01", "09"],
        h ["09", "02", "08"],
        a ["13", "01", "0e"],
        _ ["01", "01", "0c"],
        k ["09", "05", "10"],
        n ["01", "12", "0a"],
        o ["08", "0b", "12"],
        w ["11", "04", "13"],
        _ ["01", "01", "0c"],
        a ["13", "01", "0e"],
        b ["12", "00", "0e"],
        o ["08", "0b", "12"],
        u ["01", "0f", "0b"],
        t ["03", "0b", "00"],
        _ ["01", "01", "0c"],
        r ["07", "05", "04"],
        o ["08", "0b", "12"],
        l ["08", "18", "0f"],
        l ["08", "18", "0f"],
        i ["0e", "1c", "0f"],
        n ["01", "12", "0a"],
        g ["10", "15", "11"],
        _ ["01", "01", "0c"],
        d ["06", "16", "0a"],
        o ["08", "0b", "12"],
        w ["11", "04", "13"],
        n ["01", "12", "0a"],
        _ ["01", "01", "0c"],
        i ["0e", "1c", "0f"],
        n ["01", "12", "0a"],
        _ ["01", "01", "0c"],
        t ["03", "0b", "00"],
        h ["09", "02", "08"],
        e ["04", "0d", "10"],
        _ ["01", "01", "0c"],
        d ["06", "16", "0a"],
        e ["04", "0d", "10"],
        e ["04", "0d", "10"],
        p ["11", "0f", "05"],
        } ["07", "17", "02"]
    ]
    

    とりあえず全部小文字のまま提出しましたが、当然弾かれます…不定性は小文字・大文字・leet にあります。submit でブルートフォースすればなんとかなりそうだけど、流石にルール違反 (時間をかけてやれば scoreboard server への攻撃には該当しないかもだが、流石に自重した) なのでやめました。これはどうしようもない。

    ということでここまでわかったけどなんかいい方法ある?とチームに雑に投げたら、 angr でやったけど上手く行かなかった script が共有されました。自分は angr に詳しくないので何をやっているかは不明なのですが、 godbox などを呼んだあとの q0 register の値だけは正しい値を格納していそうなことだけわかりました。これを使って大文字小文字leetそれぞれを入力したときの値を調べることでフラグを調べました。

    LINECTF{watcha_kn0w_ab0ut_r0ll1ng_d0wn_1n_th3_d33p}

    筋肉だけで結構惜しいところまで求まっていた。

    TODO: angr スクリプトある程度理解してここに追記する。なんなら angr で自動で解きたい (ここまでできてるなら解けるはずでしょ)

    復習

    Pwn: trust code

    TODO: pwn っぽいことしなくても AES-CBC の特性を上手く使えば任意シェルコード送れそう、と思っているところでチームの人が正攻法で突破してくれたので、自分でも解き直してみる。

    Rev: rolling

    TODO: angr でちゃんと解く