CTFするぞ

CTF以外のことも書くよ

Defense against Game Hackに参加しました【crackme解説】

はじめに

4月16日に大崎で開かれたninjastars様によるGhidraの勉強会に参加させていただきました。 なんかGhidra使いづらかったので途中から完全にida使ってました。すいません。

内容

内容は以下の三本立てでした。

  • チートの現状とninjastarsによる対策手法
  • ghidraでcrackmeを解く
  • Cheat Engineでチート検知付きゲームをクリアする

この記事では配布された10問のcrackmeのwriteup(?)を書きます。 勉強会ではcrkme01.exeとcrkme04.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は配布されていました。