はじめに
4月16日に大崎で開かれたninjastars様によるGhidraの勉強会に参加させていただきました。 なんかGhidra使いづらかったので途中から完全にida使ってました。すいません。
内容
内容は以下の三本立てでした。
- チートの現状とninjastarsによる対策手法
- ghidraでcrackmeを解く
- Cheat Engineでチート検知付きゲームをクリアする
この記事では配布された10問のcrackmeのwriteup(?)を書きます。 勉強会ではcrkme01.exeとcrkme04.exeだけを扱っていたので、他の参加者の方の参考になればと思います。
- はじめに
- 内容
- crkme01.exe
- crkme02.exe
- crkme02a.exe
- crkme03.exe
- crkme04.exe
- crkme04a.exe
- crkme05.exe
- crkme06.exe
- crkme07.exe
- crkme08.exe
- crkme09.exe
- crkme10.exe
- 終わりに
crkme01.exe
10問ともプログラムの基本構造は同じです。 GUI製で解析が面倒なので、テキストボックスから文字列を取得するGetWindowText関数に当たりをつけ、それを使っている場所(XREF)周辺を解析します。 crkme01.exeではstrcmpで比較しています。
答え:
doomo
crkme02.exe
次のように1バイトずつ比較しています。
cmp byte ptr [esi], 35h jnz short locret_401271 ...
答え:
5EH9V3QW
crkme02a.exe
aが付いているものはちょっと難読化されてるっぽいです。 とにかくMessageBox前のnear callへジャンプするとグラフビューには表示されない次のような処理があります。
text:00401233 aJH0 db 'j~h-0@',0 ; CODE XREF: DialogFunc+1C0↑p .text:00401233 ; DATA XREF: DialogFunc+1A2↑o .text:0040123A ; --------------------------------------------------------------------------- .text:0040123A push hWnd .text:00401240 call GetWindowTextA .text:00401245 call sub_401280 .text:0040124A test eax, eax .text:0040124C jz short loc_401267
さらに呼ばれている関数の中では次のように1文字ずつチェックしています。
call sub_4012EE cmp [esi], dl ...
この関数の中は次のようになっていて、グローバル変数を使いまわしています。
seed = ROL(((seed ^ 0x3dcb7c5b) + 0x6fd76ef5), 7) & 0xffffffff; return (seed % 0x1a) + 0x41;
また、seedの最初は0x4321なので、これをもとにパスコードを生成できます。
ROL = lambda x, n: ((x << n) | (x >> (32 - n))) seed = 0x4321 passcode = "" for i in range(8): seed = ROL(((seed ^ 0x3dcb7c5b) + 0x6fd76ef5) & 0xffffffff, 7) & 0xffffffff c = (seed % 0x1a) + 0x41; passcode += chr(c) print(passcode)
答え:
WMFTPVOG
最初ROLをSHLだと勝手に思ってて、動的解析しちゃいました。 動的解析してブレークポイントをつけると「そういう所に BP 仕掛けますか(笑)」と出力されて終了します。 これは0x4011e9あたりにある処理で、call先のアドレスから0x91バイトを「int 3」を表す0xccでないか比較しているためです。 したがって、ハードウェアブレークポイントを使えば問題なく動的解析できます。
crkme03.exe
次のような処理があります。
loc_401200: mov edi, offset aVp12nggoqa ; "vP12NGgoQa" mov al, [ecx+edi] mov ebx, ecx and ebx, 1 add eax, ebx cmp al, [ecx+esi] jnz short loc_401233 inc ecx cmp ecx, 0Bh jb short loc_401200
2文字に1文字は文字コードに1を足しています。 この結果を見ているので同じ処理をpythonで書いてみましょう。
code = "vP12NGgoQa" ans = "" for i, c in enumerate(code): ans += chr(ord(c) + (i & 1)) print(ans)
答え:
vQ13NHgpQb
crkme04.exe
別の関数を呼んで結果を0x2C2E60F9と比較しています。
call sub_401224 cmp eax, 2C2E60F9h
内容はこんな感じです。
.text:00401224 push ebp .text:00401225 mov ebp, esp .text:00401227 xor ebx, ebx .text:00401229 mov esi, [ebp+flag] .text:0040122C .text:0040122C loc_40122C: ; CODE XREF: sub_401224+23↓j .text:0040122C lodsb .text:0040122D cmp eax, 0 .text:00401230 jz short loc_40124B .text:00401232 cmp eax, 30h .text:00401235 jb short loc_401249 .text:00401237 cmp eax, 39h .text:0040123A ja short loc_401249 .text:0040123C sub eax, 30h .text:0040123F xchg eax, ebx .text:00401240 imul eax, 0Ah .text:00401243 add ebx, eax .text:00401245 xor eax, eax .text:00401247 jmp short loc_40122C .text:00401249 ; --------------------------------------------------------------------------- .text:00401249 .text:00401249 loc_401249: ; CODE XREF: sub_401224+11↑j .text:00401249 ; sub_401224+16↑j .text:00401249 xor ebx, ebx .text:0040124B .text:0040124B loc_40124B: ; CODE XREF: sub_401224+C↑j .text:0040124B mov eax, ebx .text:0040124D leave .text:0040124E retn 4
文字コードが0x30から0x39の範囲でなければ0が返され、範囲内であれば0x30を引いて10掛けた値を足しています。 どうやら文字列を数値に変換する関数のようです。 したがって、比較対象の値がパスワードになります。
答え:
741236985
crkme04a.exe
関数を呼んだ結果が0でなければOKです。
call sub_40121D test eax, eax jz short loc_401218
sub_40121D
ではsub_401248
を呼んでいます。
sub_401248
の結果の上位16bitが下位16bitより小さくて、かつ上位16bitを下位16bitで掛けた結果が0x99ba4a95ならOKです。
この数字を素因数分解すると2579122837 = 42197 x 61121という2つの素数の積で表されます。
したがって、sub_401248
の結果の上位16bitは42197、下位16bitは61121である必要があります。
ということで、sub_401248
の結果が0xa4d5eec1になるようなコードを調べましょう。
sub_401248
を読むと、crkme04.exeと同じく文字列を数値に変換する処理をしています。
したがって、そのまま0xa4d5eec1=2765483713を入力すればOKです。
答え:
2765483713
crkme05.exe
別の関数を呼んで結果が0かを見ています。
call sub_40121C test eax, eax
さらにこの関数では別の関数が呼ばれています。(StringにはGetWindowTextで取得された入力文字列が入っています。)
push offset String call sub_40124E add eax, 4D2h mov ecx, 0Dh xor edx, edx div ecx sub eax, 3DBh mov ecx, 64h mul ecx cmp eax, 0 jnz short loc_40124B
この関数の中身はcrkme04.exeのものと同じで、文字列を数値に直しているだけのようです。
sub_40121C
のコードを読むと、次のような処理をしていることがわかります。
if (((toInteger(String) + 0x4d2) / 0xd - 0x3db) * 0x64 == 0) { return 1; } else { return 0; }
したがって、(x + 0x4d2) / 0xd - 0x3db == 0となるようなxを求めれば良いです。
答え:
11597
crkme06.exe
レジスタを多用しているため読みにくくなっていますが、最後にebxが0になるように辿っていけばOKです。
最初のブロックでは先頭2バイトを0x4f45と比較しているので、先頭2文字はEO
です。
mov esi, offset String movsx eax, word ptr [esi] mov ecx, 2 cmp ax, 4F45h jz short loc_40121F
次に3文字目(ecx+esi)の文字コードが0x30から0x39の間であるか調べているので、3文字目は数字です。 ecxはこの段階で2ですが、ecxが0xA未満であればループして調べているので2文字目から9文字目までは数字です。 また、ecxが7以上のときは、初め0だったedxに文字コードから0x30引いたものを足していっています。 足された結果は最後に0xAで割られ、余りが0であればOKです。 したがって、"EO"から始まりその後8文字数字が続き、最後の3つの数字の和が10の倍数であればOKです。
答え:
EO00000055
crkme07.exe
今までとは様子が代わり、4桁のシリアルコードを4つ入力して登録します。 不正解なら「未登録」と表示されたままになるので、正解のときはここの文字が変わると予想できます。 したがって、SetWindowText関数を使っている周辺を読みます。 今回はGetDlgItemではなくGetDlgItemIntが使われていますので、入力は4つの数値として受け取ります。 この時点でシリアル番号は数字から構成されることがわかります。 4つ入力ボックスがあるので4つ数値が取得されるのですが、オブジェクトIDはedxに入っており、0x40311dに結果が格納されます。 初めebxに0x40311dが入れられ、ebxはループ1回で2バイトずつ増えるので間違いなさそうです。
4つの値を格納した後、Cに翻訳すると次のような処理をしています。
unsigned short array[4]; int x, y; x = (array[0] * 0x66666667) >> 32; if ((x >> 2) != 0x3e7) { // nope } //array[3] = 0; if (array[2] > 0x270f || array[3] > 0x270f) { // nope } x = (array[2] * 0x51eb851f) >> 32; y = array[2] + ((x >> 5) + array[1]) + 0x8d; w = table[y / 0x64]; // bl /* x = (array[1] * 0x51eb851f) >> 32; y = (array[0] * 0x51eb851f) >> 32; z = (array[0] + (x >> 5) + (y >> 5)) / 0x64; */ if (0x26 + 4*5*(5*w) != array[3]) { // nope }
意味の無い処理とか入ってて大変読みづらかった。
まず1つ目の番号は簡単に計算できます。
for x1 in range(0x10000): if (x1 * 0x66666667) >> 34 == 0x3e7: break print(x1)
9990が出力されました。
2つ目と3つ目の値はとくに決まった条件はなく、3つ目と4つ目は0x270fを超えなければOKです。 したがって、2つ目と3つ目を適当に0と置けば自動的に4つ目の値が計算できます。 tableは次のidcで抽出しました。
auto addr = table; auto size = 0x90 - 0x2d; auto f = fopen("/tmp/values.dmp", "wb"); savefile(f, 0, addr, size); fclose(f);
最終的な計算コードは次のようになります。
with open("values.dmp", "rb") as f: table = f.read() for x1 in range(0x10000): if (x1 * 0x66666667) >> 34 == 0x3e7: break x2 = x3 = 0 x = (x2 * 0x51eb851f) >> 32 y = x2 + ((x >> 5) + x3) + 0x8d w = table[y % 0x64] x4 = 0x26 + 4*5*(5*w) print("{0:04}-{1:04}-{2:04}-{3:04}".format(x1, x2, x3, x4))
答え:
9990-0000-0000-0238
crkme08.exe
今度はNameとPassがあります。 GetDlgItemText周辺を読むと、次のような処理があります。
push offset name call sub_4012A8 mov edx, eax push offset pass call sub_4012D0 cmp eax, edx jz short loc_40128F
2つの関数があり、名前とパスをそれぞれ入力した際の出力が等しければ良さそうです。 まずは1つ目の関数f1を解析しましょう。
int x = 0; while(*name != 0) { x += *name; x *= x; } x += 0x183fa42b; if (x == -1) { x -= 1; }
ふーん、よくわからん。
次に2つ目の関数f2です。
こちらは16進数の文字列を1文字ずつ読み込み、1桁ずつの総和を取っています。
※後で判明したのですが、単に16進数文字列を16進数値として変換してました。
16進数以外の入力が入ると-1が返ります。
だから1つ目の関数で-1を-2に変換していたんですね。
nameの方が厳しいので先にnameを考えます。
あまり出力を大きくしたくないので、出力が小さくなるようなnameを探しましょう。
というのもpassの長さは8バイト固定なので、f2は0以上120以下の値を取る必要があります。
※f2は0以上0xffffffff以下の値を取る必要があります。
次のコードで0<=f1(name)<=120
なるnameを探したところ、10秒程度で結果が出ました。
import string import random def int32(x): if x>0xFFFFFFFF: raise OverflowError if x>0x7FFFFFFF: x=int(0x100000000-x) if x<2147483648: return -x else: return -2147483648 return x def f1(name): x = 0 for c in name: x += ord(c) x *= x x = int32(x & 0xffffffff) x += 0x183fa42b x = int32(x & 0xffffffff) if x == -1: x -= 1 return x table = string.ascii_letters + string.digits while True: name = ''.join([random.choice(table) for i in range(0x10)]) if 0 <= f1(name) <= 120: print(name) print(f1(name)) break
結果:
GQ9zRCkEQ4kETpvW 107
ということで、nameとして
勘違いして勝手に問題を難しくしていましたが、もっと単純でしたね。
やりなおし:GQ9zRCkEQ4kETpvW
を、passとして各桁の総和が107になるような16進数列を渡せばOKです。
def int32(x): if x>0xFFFFFFFF: raise OverflowError if x>0x7FFFFFFF: x=int(0x100000000-x) if x<2147483648: return -x else: return -2147483648 return x def f1(name): x = 0 for c in name: x += ord(c) x *= x x = int32(x & 0xffffffff) x += 0x183fa42b x = int32(x & 0xffffffff) if x == -1: x -= 1 return x print(hex(f1("A")))
答え:
Name: A Pass: 183fb4ac
個人的にはcrkme07よりだいぶ簡単です。
crkme09.exe
この辺からパックされてるかなーと思ったらビンゴで、UPX圧縮されていました。
$ file crkme09.exe crkme09.exe: PE32 executable (GUI) Intel 80386, for MS Windows, UPX compressed
まずはデコンプレスしましょう。
$ upx -d crkme09.exe -o crkme09d.exe
今回もnameとpassを聞かれます。
GetDlgItemText周りを見ると、passの長さは0x16バイトです。
その後nameをチェックする関数(sub_4012B5
)と、passをチェックする関数(sub_401329
)が呼ばれます。
nameのチェックでは、まず各文字に対して次の処理がされます。
movsx ecx, byte ptr [esi] xor ecx, 3DCB7C5Bh add ecx, 6FD76EF5h rol ecx, 7 add eax, ecx inc esi
なお、eaxの初期値は0です。 その後8回次の処理が走ります。 eaxを16進数にしたときの各桁に対して処理している感じです。
mov ebx, eax and ebx, 0Fh movsx edx, table[ebx] mov [esi], dl inc esi shr eax, 4 dec ecx
esiにはグローバル変数が入っており、ここにエンコード後のnameが入ります。
また、tableの中身はS1AC4QM5EGOU8IKZRG6WXPD7NT9BV2HJ
です。
最後に[esi]に0x2dを入れ、一番最初に計算したeaxに次の処理をし、また8回ループします。
rol eax, 0Dh xor eax, 3DCB7C5Bh rol eax, 7
mov ebx, eax and ebx, 0Fh add ebx, 10h movsx edx, table[ebx] inc esi mov [esi], dl shr eax, 4 dec ecx
同様に2つ目の関数も調べます。 全体のおおまかな処理をpythonで書くと次のようになります。
import struct ROL = lambda x, n: ((x << n) | (x >> (32 - n))) def sub_4012B5(name): seed = 0 encoded = b"" table = b"S1AC4QM5EGOU8IKZRG6WXPD7NT9BV2HJ" for c in name: seed += ROL(((ord(c) ^ 0x3dcb7c5b) + 0x6fd76ef5) & 0xffffffff, 7) & 0xffffffff eax = seed for i in range(8): encoded += bytes([table[eax & 0xF]]) eax >>= 4 encoded += bytes([0x2d]) eax = ROL((ROL(seed, 0xD) & 0xffffffff) ^ 0x3dcb7c5b, 7) & 0xffffffff for i in range(8): encoded += bytes([table[(eax & 0xF) + 0x10]]) eax >>= 4 return encoded def sub_401329(passcode, encoded): if struct.unpack('<I', passcode[:4])[0] - 0x283a4132 != 0x11111111: return 0 if passcode[4] - 0x2e != -1: return 0 for i in range(0x11): if encoded[i] - passcode[5 + 0x10 - i] != 0: return 0 return 1
適当に名前を設定して適切なpasscodeを計算しましょう。
name = "taro" encoded = sub_4012B5(name) passcode = struct.pack('>I', 0x11111111 + 0x283a4132) passcode += bytes([-1 + 0x2e]) for i in range(0x11): passcode += bytes([encoded[0x10 - i]]) print(name, passcode, encoded)
ここで、名前をtaroにするとpassはCRK9-RRWH72J9-4QIM4KQE
となったのですが、不正解となりました。
どうも1文字違うところがあるようです。
しばらくidaと比べましたが何がおかしいのか分かりませんでした。
まぁやってることは"CRK9-"にencodedを反転して足してるだけなので、適当な名前を入力してデバッガでencodedを見ればパスも求まります。
答え:
Name: taro Pass: CRK9-RRW97RJ9-4QIM4KQE
crkme10.exe
今日はCPCTFもあって眠いのでここまでにします。 気が向いたら更新するかも。(たぶんしない)
終わりに
久しぶりにcrackmeを解いて楽しかった。 無料でこういうの開催してくれるのは本当にありがたいです。 ninjastarsのスタッフの方々、問題作成から会場設置までお疲れ様でした。
要望
- あらかじめ電源タップ用意してもらえると助かります。
- もう少し早めに必要なツールとか教えてもらえれば余裕を持って準備できました。
疑問
- 時間の問題で説明が早かったと思うのですが、割と多くの参加者が付いて来れてなかったのでは?
Cheatの方のバイナリってどこかで配布されてますか?Level 1は配布されていました。