はじめに
SECCON 2016 Online CTF に参加して、 Retrospective (Binary 200) の問題に挑戦した。 残念ながらコンテスト期間中には解ききれなかったのだけれど、過程のメモを残しておく。
実行ファイルを動かしてみる
file
というファイルが与えられていて、見てみるとWin32の実行ファイルのようで、Macで作業していたので、とりあえずwineをインストール。
$ brew install wine
wine上で実行していると、色々DLLとかがないと言われて言われたので、とりあえずwinetrickでインストール。
$ brew install winetricks
$ winetricks vb6run
$ winetricks comdlg32ocx
$ winetricks richtx32
$ winetricks comctl32ocx
これで実行ファイルが動くようになった。 動かしてみると、どうも何か入力して、それがチェックを通ればフラグになっているという、よくあるパターンの問題っぽい。
実行ファイルを覗いてみる
例によって、Hopperでデコンパイルしてみるか、と思って試してみたが、あまり意味のある情報が得られない。 調べてみると、 Visual Basic 6 で書かれていて P-Code という謎の中間言語を解釈するための関数を呼び出しているだけのよう。
ディスアセンブル結果の解釈 (本番中の試み)
調べていると、VB Decompiler や P32Dasm - VB5/VB6 PCode Decompiler というP-Codeのデコンパイラやディスアセンブラがあるっぽいので、それらを試した結果が、それぞれ以下。
- https://github.com/msakai/SECCON2016_Retrospective/tree/master/vb_decompiler_lite_result
- https://github.com/msakai/SECCON2016_Retrospective/blob/master/p32dasm_result/file.txt
VB Decompiler Lite の結果の方を主に見てみると、P-Code の命令の詳細は分からないものの、スタックマシンっぽい感じで、概ね Form1.frm の Command1_Click
にOKボタンを押した際の処理があり、入力を色々チェックしてOKだったら、それがフラグというよくあるパターン(DEF CON CTF 2016 の baby-re や HITCON CTF 2016 の ROP もそういう問題だった)のよう。
P-Codeの詳細は分からないのだけれど、最後のチェックは calchash.bas の Proc_2_0_403028
に入力を渡して帰ってきた結果が "8B292F1A-9C4631B3-E13CD49C-64EF7454-0352D0C0"
と等しければOKというものっぽっかったので、コンテスト中はまずはcalchash.basの解読にかかった。
……が、P-Codeについてはまとまった資料が見つからず命令名から機能を推測する必要があり、かつその際には Visual Basic の言語自体についても全く詳しくないのでUBound
とかRedim
とかのVB用語の意味をいちいち調べて確認しなければならないのが大変で、かつ VB Decompiler Lite の出力は一部情報が欠損しているしでかなり難航。 欠損箇所はP32Dasmの結果から補ったりしたのだけれど、結局時間中には解読しきれず。
ディスアセンブル結果の解釈 (終了後の試み)
終了後にも引き続き解読を試みた結果が decompiled.rb (一部デバッグプリントなども含まれている)。
calchash.bas だけでなく、Form1.frm の Command1_Click
の方の解読も試みて、こっちはcalchash.basとはだいぶ違う命令が使われていたり、Mid
関数が文字列中の位置を0始まりではなく1始まりで数えてたり、こっちはこっちで色々嵌った部分もあったけれど、何とか解読できた感じ。
Form1.frmを見ると、テキストボックスのMaxLengthが28であることが分かる。 また、Text1_KeyPress
を見ると、A-Z
および {}_\b
以外は入力できなかくなっていることが分かり、\b
はバックスペースなので、フラグは A-Z
および {}_
の文字だけから構成されることが分かる。
Command1_Click
の処理を見ていくと、まず先頭が"SECCON{"
であることと、末尾が"}"
であることをチェックしている。
次に、"_"
でsplitした最初の要素の8文字目(1始まりなので先の"SECCON{"
の直後)からが"LEGACY"
であることをチェックしている。
その次に、splitした2個目の要素をs
とすると、その長さが2であることと、s[0] + s[1]*4 = 350
であることをチェックしている。前述の文字の範囲でこれを満たすのは"VB"という文字列だけである。
その次に、splitした3個目の要素をn
文字の文字列とすると、(s[0] + s[1]*4)*256 + (s[0] + … + s[n-1]) = 0x15E56
であることをチェックしている。 (s[0] + s[1]*4) = 350
だったので、s[0] + … + s[n-1] = 0x15E56 - 350*256 = 86
である。 また、s
は2文字と分かっているので、nは0,1,2のいずれか(ここにそれ以上の文字列を入れるとプログラムがクラッシュする)で、'V'=86なので n=1 である。
ここまでで入力が SECCON{LEGACY[A-Z{}]*_VB_[A-Z{}]_[A-Z_{}]*}
という正規表現で表される言語に属する、28文字以下の文字列であることが分かった。キーワードの意味的に"LEGACY"
の後の文字列はとりあえず空であると思っておく。
続いて、文字列全体をt
としたときに $\sum_{i=0}^{len(t)-1} 2^i t_i \equiv \mathtt{0x620F3671} \mod 2^{32}$ であることチェックしている。 "LEGACY"
の後の文字列が空という前提で探索をしてみると、"VB_"
の後の一文字はDHLPTXのいずれかであることが分かるが、以降の文字列の選択肢は膨大なので、これ以上絞り込むのは難しい。
最後のチェックは、前述の calchash.bas の Proc_2_0_403028
に文字列全体を渡して帰ってきた結果が "8B292F1A-9C4631B3-E13CD49C-64EF7454-0352D0C0"
と等しいかというチェックなのだけれど、Proc_2_0_403028の解読が完全ではないこともあり、ここで詰まってしまった。
ハッシュ関数の特定
たまたまTwitterを見ていたら、「解けた」と言っている人を発見。
Retrospective解けました!!!https://t.co/hMxyqu1FPp#SECCON
— 【TomoriNao】きりん (@kkrnt) 2016年12月13日
この人の回答を見ると、ハッシュ関数はSHA1だったらしい。 calchash.bas のディスアセンブル結果を厳密に解釈するのを頑張るのではなく、出力サイズや処理内容の概要から推測するのが正解だったか……
【追記】 https://github.com/Inndy/ctf-writeup/tree/master/2016-seccon/retrospective によると、「SHA1, because of some magic constant value from module」だそうで、分かる人にはそれでSHA1と推測できたのか……
解の探索
"LEGACY"の後の文字列が空という前提で、SECCON{LEGACY_VB_[A-Z{}]_[A-Z_{}]*}
のうち、$\sum_{i=0}^{len(t)-1} 2^i t_i \equiv \mathtt{0x620F3671} \mod 2^{32}$ を満たす文字列を短い方から列挙し、SHA1が"8B292F1A-9C4631B3-E13CD49C-64EF7454-0352D0C0"
と一致するか判定させてみた。
import Control.Monad
import qualified Crypto.Hash as H
import Data.Bits
import qualified Data.ByteString.Char8 as B
import Data.Char
import System.Exit
import System.IO
xss :: [String]
xss = do
let prefix = "SECCON{LEGACY_VB_"
let g 0 ss = ss
g n ss = ss ++ g (n-1) [s' | s <- ss, c <- ['A'..'Z']++['_','{','}'], let s' = s++[c], f s']
s <- g 8 [s2 | c1 <- ['A'..'Z']++"{}", let s1 = prefix ++ [c1], f s1, let s2 = s1 ++ "_", f s2]
guard $ f (s ++ "}")
return (s ++ "}")
where
f :: String -> Bool
f s = sum [fromEnum c * 2^i | (i,c) <- zip [0..] s] .&. m == 0x620F3671 .&. m
where
m = 2 ^ length s - 1
main :: IO ()
main =
forM_ xss $ \xs -> do
let d :: H.Digest H.SHA1
d = H.hash (B.pack xs)
when (show d == map toLower (filter ('-'/=) "8B292F1A-9C4631B3-E13CD49C-64EF7454-0352D0C0")) $ do
putStrLn xs
print d
hFlush stdout
exitSuccess
答えが長い文字列だと難しいかと思ったけれど、幸い3秒ほどで「SECCON{LEGACY_VB_P_CODE}
」という答えが得られた。
これを入力してOKを押すと以下のようなメッセージが表示された。
Thank you for your purchase :-)
And SECCON{LEGACY_VB_P_CODE} is Flag.
おしまい。
感想
まったく馴染みのない言語や仮想マシンのコードの解析は、推測に推測を重ねなければならず、かなり辛い。そういう意味では、DEF CON CTF 2016 の baby-re や HITCON CTF 2016 の ROP の問題は恵まれていたなぁ……
そのうえで、解析を厳密に頑張るよりは、ハッシュ関数がSHA1であることを推測したように推測でアタリをつける方法や、今回は使わなかったけれどデバッガとかを使って動的解析を併用して推測結果が正しいかを確認したり、といった方が現実的な取り組みだったのではと思う。
あと、ディスアセンブラでしかない VB Decompiler Lite ではなく、ちゃんとデコンパイルしてくれる VB Decompiler を使えば、だいぶ省力化できる気はしつつも、今更VB6の解析ツールに投資しても他に役立つ未来が見えなくて、自分は買わなかった。 けど、 https://github.com/Inndy/ctf-writeup/tree/master/2016-seccon/retrospective の人とかは、 VB Decompiler 使ってたのね……