ImaginaryCTF 2024 脆弱Writeup
結果
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の右手法じゃん!この時アセンブリで書いてるから余裕でしょって、チームメンバーにやらされました。
タイトルが「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リンクから、気軽にご参加ください!
Pwn担当大歓迎(切実)
Discussion