ImaginaryCTF 2024 脆弱Writeup

2024/07/23に公開

結果

ImaginaryCTF 2024をチーム「脆弱エンジニア(full_weak_engineer)」で参加してきました。
31位/1457チームでした!

チームメンバーが強いのでこのような結果になりました。自分はもっと頑張らねば...。

解けた問題

自分は5問解くことができました。面白かった3問の解法を書いておきます。

  • integrity (crypto, 100pts) - 172 solves
  • solitude (crypto, 331pts) - 46 solves
  • starship (misc, 100pts) - 205 solves
  • packed (forensics, 100pts) - 605 solves
  • Left in the Dark (misc, 428pts) - 30 solves

integrity (crypto, 100 pts, 172 solves)✅

問題

RSAの暗号結果と、独自のsignatureが与えられる。

from Crypto.Util.number import *
from binascii import crc_hqx

p = getPrime(1024)
q = getPrime(1024)

n = p*q
e = 65537
tot = (p-1)*(q-1)
d = pow(e, -1, tot)

flag = bytes_to_long(open("flag.txt", "rb").read())
ct = pow(flag, e, n)

#signature = pow(flag, d, n) # no, im not gonna do that
signature = pow(flag, crc_hqx(long_to_bytes(d), 42), n)

print(f"{n = }")
print(f"{ct = }")
print(f"{signature = }")
n = 10564138776494961592014999649037456550575382342808603854749436027195501416732462075688995673939606183123561300630136824493064895936898026009104455605012656112227514866064565891419378050994219942479391748895230609700734689313646635542548646360048189895973084184133523557171393285803689091414097848899969143402526024074373298517865298596472709363144493360685098579242747286374667924925824418993057439374115204031395552316508548814416927671149296240291698782267318342722947218349127747750102113632548814928601458613079803549610741586798881477552743114563683288557678332273321812700473448697037721641398720563971130513427
ct = 5685838967285159794461558605064371935808577614537313517284872621759307511347345423871842021807700909863051421914284950799996213898176050217224786145143140975344971261417973880450295037249939267766501584938352751867637557804915469126317036843468486184370942095487311164578774645833237405496719950503828620690989386907444502047313980230616203027489995981547158652987398852111476068995568458186611338656551345081778531948372680570310816660042320141526741353831184185543912246698661338162113076490444675190068440073174561918199812094602565237320537343578057719268260605714741395310334777911253328561527664394607785811735
signature = 1275844821761484983821340844185575393419792337993640612766980471786977428905226540853335720384123385452029977656072418163973282187758615881752669563780394774633730989087558776171213164303749873793794423254467399925071664163215290516803252776553092090878851242467651143197066297392861056333834850421091466941338571527809879833005764896187139966615733057849199417410243212949781433565368562991243818187206912462908282367755241374542822443478131348101833178421826523712810049110209083887706516764828471192354631913614281317137232427617291828563280573927573115346417103439835614082100305586578385614623425362545483289428

解法

実験してみると、crc_hqx(long_to_bytes(d), 42) が65537より小さい値をとることがわかった。
つまり、RSAにおいて、 m^e1, m^e2 がわかっている状態である。
Common Modulus Attackというやつらしい。

crc_hqx(long_to_bytes(d), 42) の値はわからないので、65536までの値で総当たりで計算した。

from Crypto.Util.number import *
from sympy import gcdex
from out import *

def find_flag(e1, c1, c2, n):
    for e2 in range(1, 65537):
        print(e2)
        x, y, _ = gcdex(e1, e2)
        try:
            potential_flag = (pow(c1, int(x), n) * pow(c2, int(y), n)) % n
            potential_flag_bytes = long_to_bytes(potential_flag)
            if b'ctf' in potential_flag_bytes:
                print(potential_flag_bytes)
                return
        except:
            continue
    return None

correct_flag = find_flag(65537, c1, c2, n)

ictf{oops_i_leaked_some_info}

solitude (crypto, 331 pts, 172 solves)✅

問題

ncで接続する問題。入力で指定した数だけ、独自の暗号結果が返ってくる。

#!/usr/bin/env python3

import random

def xor(a: bytes, b: bytes):
  out = []
  for m,n in zip(a,b):
    out.append(m^n)
  return bytes(out)

class RNG():
  def __init__(self, size, state=None):
    self.size = size
    self.state = list(range(self.size+2))
    random.shuffle(self.state)
  def next(self):
    idx = self.state.index(self.size)
    self.state.pop(idx)
    self.state.insert((idx+1) % (len(self.state)+1), self.size)
    if self.state[0] == self.size:
      self.state.pop(0)
      self.state.insert(1, self.size)
    idx = self.state.index(self.size+1)
    self.state.pop(idx)
    self.state.insert((idx+1) % (len(self.state)+1), self.size+1)
    if self.state[0] == self.size+1:
      self.state.pop(0)
      self.state.insert(1, self.size+1)
    if self.state[1] == self.size+1:
      self.state.pop(1)
      self.state.insert(2, self.size+1)
    c1 = self.state.index(self.size)
    c2 = self.state.index(self.size+1)
    self.state = self.state[max(c1,c2)+1:] + [self.size if c1<c2 else self.size+1] + self.state[min(c1,c2)+1:max(c1,c2)] + [self.size if c1>c2 else self.size+1] + self.state[:min(c1,c2)]
    count = self.state[-1]
    if count in [self.size,self.size+1]:
      count = self.size
    self.state = self.state[count:-1] + self.state[:count] + self.state[-1:]
    idx = self.state[0]
    if idx in [self.size,self.size+1]:
      idx = self.size
    out = self.state[idx]
    if out in [self.size,self.size+1]:
      out = self.next()
    return out

if __name__ == "__main__":
  flag = open("flag.txt", "rb").read()
  while True:
    i = int(input("got flag? "))
    for _ in range(i):
      rng = RNG(128)
      stream = bytes([rng.next() for _ in range(len(flag))])
      print(xor(flag, stream).hex())

解法

独自の乱数をフラグとXORしている。
独自の乱数ということで、100000回ほど生成して頻度を測定してみると、0が明らかに多いことが判明した。

出現回数
0 1595
16 844
79 844
... ...

というわけで、それぞれの文字において、もっとも多い頻度の値を取り出す。
0とのXORは変わらないので、XORの処理は不要。

from pwn import *
from collections import Counter

address = 'solitude.chal.imaginaryctf.org'
port = 1337
io = remote(address, port)

num = 100000

io.recvuntil('got flag? ')
io.sendline(str(num))


def most_common_bytes(byte_strings, n, top_n=1):
    nth_bytes = [byte_str[n] for byte_str in byte_strings]
    frequency = Counter(nth_bytes)
    most_common_bytes = frequency.most_common(top_n)[0][0]
    return most_common_bytes

blist = []
for i in range(num):
    x = io.recvline()[:-1].decode()
    print(x)
    blist.append(bytes.fromhex(x))

flag=b''
for i in range(len(blist[0])):
    flag += most_common_bytes(blist, i).to_bytes()

print(flag)

ictf{biased_rng_so_sad_6b065f93}

Left in the Dark (misc, 428 pts, 30 solves)✅

問題

socat FILE:tty,raw,echo=0 TCP:left-in-the-dark.chal.imaginaryctf.org:1337 と接続して解く問題。

W,A,S,Dで移動して、壁があれば「BONK」と返ってきて、なければ応答がない。その状態で、迷路のゴールまで辿り着けという問題。

今どこにいるか、迷路がどんなのか状況はわからない。(まっくら)

#!/usr/bin/env python3

from random import choice
from copy import deepcopy
# https://pypi.org/project/console/
from console.utils import wait_key

class Maze:
    def __init__(self, dim, size):
        self.dim = dim
        self.size = size
        self.maze = '#'
        self.loc = tuple([0]*dim)
        for i in range(dim):
            self.maze = [deepcopy(self.maze) for _ in range(size)]
        self.gen()

    def __str__(self):
        if type(self.maze[0]) == str:
            return ''.join(self.maze)+'\n'
        ret = ''
        for i in self.maze:
            temp = deepcopy(self)
            temp.dim -= 1
            temp.maze = i
            ret += str(temp)
        ret += "\n"
        return ret

    @staticmethod
    def fromstr(s):
        dim = 0
        for i in s[-max(len(s), 50):][::-1]:
            if i == '\n':
                dim += 1
            else:
                break
        size = 0
        for i in s[:max(len(s), 50):]:
            if i == '\n':
                break
            size += 1

        ret = Maze(2, 2)
        ret.maze = Maze.fromstrhelp(s, dim, size)
        ret.dim = dim
        ret.size = size
        ret.loc = tuple([0]*dim)
        return ret

    @staticmethod
    def fromstrhelp(s, dim, size):
        s = s.strip()
        if dim == 1:
            return list(s)
        return [Maze.fromstrhelp(i+'\n'*(dim-1), dim-1, size) for i in s.split('\n'*(dim-1))]


    def get(self, *pt):
        ret = self.maze
        for idx in pt:
            ret = ret[idx]
        return ret

    def set(self, *pt, **kwargs):
        temp = self.maze
        for idx in pt[:-1]:
            temp = temp[idx]
        temp[pt[-1]] = kwargs['val']

    def check(self, *pt):
        for i in pt:
            if i >=self.size or i < 0:
                return False
        return True

    def adj(self, *pt):
        ret = set()
        for i in range(len(pt)):
            newpt = [i for i in pt]
            newpt[i] += 1
            if self.check(*newpt):
                ret = ret | {tuple(newpt)}
            newpt[i] -= 2
            if self.check(*newpt):
                ret = ret | {tuple(newpt)}
        return ret

    def neighbors(self, *pt, typ=None):
        ret = set()
        for pt in self.adj(*pt):
            if typ is None or self.get(*pt) in typ:
                ret = ret | {pt}
        return ret

    def gen(self):
        self.set(*self.loc, val=' ')
        walls = self.adj(*self.loc)

        while len(walls) > 0:
            rand = choice(list(walls))
            nbhd = self.neighbors(*rand, typ=' ')
            if len(nbhd) == 1:
                self.set(*rand, val=' ')
                walls = walls | self.neighbors(*rand, typ='#')
            walls = walls - {rand}

        self.set(*([0]*self.dim), val='@')
        for i in self.neighbors(*([0]*self.dim)):
            self.set(*i, val=' ')

        self.set(*([self.size-1]*self.dim), val='F')
        for i in self.neighbors(*([self.size-1]*self.dim)):
            self.set(*i, val=' ')

    def move(self, mv):
        newLoc = (self.loc[0] + mv[0], self.loc[1] + mv[1])
        if (
            newLoc[0] < 0 or newLoc[0] >= self.size or
            newLoc[1] < 0 or newLoc[1] >= self.size or
            self.get(*newLoc) == '#'
        ):
            print("BONK")
            return
        if self.get(*newLoc) == 'F':
            print(open("flag.txt").read())
            wait_key()
            exit(0)
        self.set(*self.loc, val=' ')
        self.set(*newLoc, val='@')
        self.loc = newLoc

def getKey():
    key = wait_key()
    if key == chr(3): # Ctrl-C
        exit(1)
    return key

moveDict = {
    'w': (-1, 0),
    's': (1, 0),
    'd': (0, 1),
    'a': (0, -1),
}

def waitForMove():
    key = None
    while key not in moveDict:
        key = getKey()

    return moveDict[key]
    

def main():
    maze = Maze(2, 40)
    print("Find the flag in this maze. Good luck!")
    print("WASD to move.")
    while True:
        # print(maze)
        move = waitForMove()
        maze.move(move)

if __name__ == '__main__':
    main()

解法

これ、以前やったTuring Completeの右手法じゃん!この時アセンブリで書いてるから余裕でしょって、チームメンバーにやらされました。
https://youtu.be/-FJcYUdMCvQ?si=4loGLS1yV1dDnyNp

タイトルが「Left in the Dark」なので左手法にしました。

from pwn import *
import copy

'''
進行方向に対して

左に壁がある(左を向く isFrontWall 右を向く)
 - 前に壁がない
  前進する
 - 前に壁がある
  右を向く
左に壁がない
 - 左を向いて前進する
'''

address = 'left-in-the-dark.chal.imaginaryctf.org'
port = 1337

io = remote(address, port)

start = io.recvline()
print(start)

dir = 0
keys = [b'w', b'd', b's', b'a']
ways = ['↑', '→', '↓', '←']

dxdy = [[0, -1], [1, 0], [0, 1], [-1, 0]]
pos = [0, 0]
maze = []
for i in range(40):
    road = ['#'] * 40
    maze.append(road)

def printMaze(maze):
    maze2 = copy.deepcopy(maze)
    maze2[pos[1]][pos[0]] = '@'
    print("\n".join(["".join(x) for x in maze2]))

def turnRight():
    global dir
    dir = (dir + 1) % 4

def turnLeft():
    global dir
    dir = (dir - 1) % 4

def go():
    global dir
    io.sendline(keys[dir])
    io.sendline(b'\r\n')
    # print(ways[dir])

def back():
    global dir, keys
    d = (dir + 2) % 4
    io.sendline(keys[d])
    io.sendline(b'\r\n')
    # print(ways[d])

def isFrontWall():
    go()
    try:
        response = io.recvline(timeout=2)
        print(response)
        if b'BONK' in response:
            # print(response)
            print("壁がある")
            return True
        elif b'ictf' in response:
            # print(response)
            pass
        else:
            # print(response)
            print("壁がない")
            back()
            return False
    except EOFError:
        print("接続が切れました")

x = io.recvuntil(b'WASD to move.\r\n')
print('print:', x)

while True:
    turnLeft()
    isLeftWall = isFrontWall()
    turnRight()
    if isLeftWall:
        if isFrontWall():
            turnRight()
        else:
            go()
            pos[0] = pos[0] + dxdy[dir][0]
            pos[1] = pos[1] + dxdy[dir][1]
            maze[pos[1]][pos[0]] = ' '
            print(pos)
            printMaze(maze)
    else:
        turnLeft()
        go()
        pos[0] = pos[0] + dxdy[dir][0]
        pos[1] = pos[1] + dxdy[dir][1]
        maze[pos[1]][pos[0]] = ' '
        print(pos)
        printMaze(maze)

ただこれ、timeout=2 としているように、ネットワークの問題か、応答がないと判断するまで時間がかかるので、かなり時間を要する。

挙げ句の果て、ネットワークエラーで切断されていた。

チームメンバーが同じコードで実行したら解けたらしい。

解きたかった問題

tango (crypto, 100pt, 101 Solves)

問題

ncで接続する問題。

「E」:3文字のコマンドを入力して、Salsa20で暗号化された結果(hex)が返ってくる。
「R」:暗号を送ると、CRC32のチェックサムを検証したのちに、jsonのuserとコマンドに応じて処理が変化する。コマンドを「flag」、userを「root」にすればフラグが得られる。

from Crypto.Cipher import Salsa20
from Crypto.Util.number import bytes_to_long, long_to_bytes
import json
from secrets import token_bytes, token_hex
from zlib import crc32

from secret import FLAG

KEY = token_bytes(32)


def encrypt_command(command):
    if len(command) != 3:
        print('Nuh uh.')
        return
    cipher = Salsa20.new(key=KEY)
    nonce = cipher.nonce
    data = json.dumps({'user': 'user', 'command': command, 'nonce': token_hex(8)}).encode('ascii')
    checksum = long_to_bytes(crc32(data))
    ciphertext = cipher.encrypt(data)
    print('Your encrypted packet is:', (nonce + checksum + ciphertext).hex())


def run_command(packet):
    packet = bytes.fromhex(packet)
    nonce = packet[:8]
    checksum = bytes_to_long(packet[8:12])
    ciphertext = packet[12:]

    try:
        cipher = Salsa20.new(key=KEY, nonce=nonce)
        plaintext = cipher.decrypt(ciphertext)

        if crc32(plaintext) != checksum:
            print('Invalid checksum. Aborting!')
            return

        data = json.loads(plaintext.decode('ascii'))
        user = data.get('user', 'anon')
        command = data.get('command', 'nop')

        if command == 'nop':
            print('...')
        elif command == 'sts':
            if user not in ['user', 'root']:
                print('o_O')
                return
            print('The server is up and running.')
        elif command == 'flag':
            if user != 'root':
                print('You wish :p')
            else:
                print(FLAG)
        else:
            print('Unknown command.')
    except (json.JSONDecodeError, UnicodeDecodeError):
        print('Invalid data. Aborting!')


def menu():
    print('[E]ncrypt a command')
    print('[R]un a command')
    print('[Q]uit')


def main():
    print('Welcome to the Tango server! What would you like to do?')
    while True:
        menu()
        option = input('> ').upper()
        if option == 'E':
            command = input('Your command: ')
            encrypt_command(command)
        elif option == 'R':
            packet = input('Your encrypted packet (hex): ')
            run_command(packet)
        elif option == 'Q':
            exit(0)
        else:
            print('Unknown option:', option)


if __name__ == '__main__':
    main()

解法

(後日復習予定)

おわりに

チーム「脆弱エンジニア(full_weak_engineer)」では、SECCON CTF決勝進出を目指してCTFに参加しています!

チームでCTFをやりたいメンバーを常に募集していますので、YouTubeの概要欄にあるDiscordリンクから、気軽にご参加ください!

https://www.youtube.com/@full-weak-engineer


Pwn担当大歓迎(切実)

Discussion