難読化の話(超!?入門編)その3 中編|セキュリティごった煮ブログ

ネットエージェント
セキュリティごった煮ブログ

 コース:こってり

難読化の話(超!?入門編)その3 中編

bubobubo

どーも、bubobuboです。

前フリ

NSA(米国家安全保障局)が、マルウェア解析を目的として開発したリバースエンジニアリングツール『GHIDRA』を公開したようです。

Ghidra
https://ghidra-sre.org/

公開すること自体は2ヵ月ほど前から予告されていましたが、先日開催された RSA Conference 2019 で『GHIDRA』を紹介したタイミングで3月6日に公開したとのことです。RSA Conference 2019では、よりによってRSAのSの人であるアディ・シャミア(イスラエル人)が米国のビザが下りず入国できなかったようです。

Adi Shamir couldn't get US visa to attend RSA Conference named for him
https://www.cnet.com/news/adi-shamir-couldnt-get-us-visa-to-attend-rsa-conference-named-for-him/

商用のリバースエンジニアリングツールとしては『IDA Pro』が事実上の一強ですが、ライセンスが高額で継続更新も必要なことや、個人で購入するには代理店を通す必要があるため、入手のハードルはなかなか高いです。

一方で、GHIDRAは完全無料でソースコードも公開予定だそうです(執筆時点ではバイナリ配布のみ)。既に高い評価がちらほら聞こえるため、ポストIDA Proとなるか注目を集めています。筆者もGHIDRAのファーストインプレッションを書きたいと思ったのですが、公開時点でブログの締め切りが1週間を切っており、かといって中途半端な記事は書きたくないので、相変わらず 難読化の話 をしたいと思います。期待させてしまってすみません。

閑話休題。

かい○ゃから前回の続編を書けと言われたので、クライアントプログラムのセキュリティの一分野である(とされている)、「プログラムの難読化」をテーマに割と真面目に作文を行いました。

難読化をテーマとしたエントリは、これで4本目になってしまいました。そろそろ飽きてきたので本エントリで一区切りつけたいと思います...が、 このエントリも長くなってしまったので、 これは中編として、次の機会に後編を書いて終わりにしたいと思います。

過去の記事はこちら。

難読化の話(超!?入門編)
難読化の話(超!?入門編)その2
難読化の話(超!?入門編)その3 前編

難読化テクニック概観 その2

難読化を実現するテクニックは複数存在しますが、前回のエントリではこれらを大雑把に2通りに分類して、前者を紹介しました。

  • 情報量を削ることで難読化を試みる方法
  • 情報量を無駄に増やすことで難読化を試みる方法

今回のエントリでは後者を紹介します。

難読化テクニックの一例

文字列の暗号化

プログラム中に埋め込まれている平文の文字列は、解析を行う上で大きなヒントになります。

たとえば、"Serial Number" とか "License Key""Invalid Code" といった機微なキーワードが残っていれば、隠したい処理があっても、文字列参照や文字列検索を使うと一発で特定されてしまいます。ここまで露骨な文字列ではなくても、"[%04d-%02d-%02d %02d:%02d%:02d] %s" のような書式文字列が目に留まると、その書式だけでもどのような値を扱っているのか(この場合ログっぽい)を類推することができます。

これでは不都合なので、一目見てわからない程度 (異論は認める) でいいから暗号化したいと思うのが人情です。プログラムの大部分をまとめて暗号化すれば、その中に含まれる文字列も一括して暗号化されますが、メモリ上(またはディスク上)にプログラムの大部分が復号された瞬間を狙われてダンプされたら意味がありません。

対抗策としては、暗号化・復号をひとまとめに行わず、小分けにする方法があります。例えば関数単位あたりでデータを区切って、本当に必要になる直前に部分的に復号して、使い終わったら速やかに削除(または再暗号化)する設計にすれば、復号されたデータがメモリ上に存在する期間は短くなるので、復号された瞬間を狙ってダンプしても、得られる平文は断片的なので、面倒になります。当然ですが、プログラムにとっても面倒な処理であり、暗号化・復号のコストも馬鹿になりません。

ここでは、文字列だけを暗号化して、必要になったときに限りオンメモリで復号することを考えます。

以下のような、Javaで記述されたベタなソースコードがあったとします。

// オリジナルのソースコード
if (IsValidCode(strLicenseNumber)) {
  showMessage("Thank you!");
} else {
  showMessage("Invalid Code");
}

上記のベタな判定処理を含むクラスファイルに対して、文字列の難読化を行った一例を示します。

// 難読化したクラスファイルをデコンパイルして得たソースコード
if (a(c)) {
  b(d.e("Sndjl&|kr'"));
} else {
  b(d.e("Nhsekoa$Diaa"));
}

変数名や関数名は、前回紹介したアルファベット1文字に置き換える方法を使っています。

文字列については、中間コード上でハードコーディングされているものを、難読化ツールが見つけ次第「暗号化」します。これだけだとプログラムが動かなくなるため、平文に戻す関数(ここでは d.e("[暗号文]"))を用意して、暗号化された文字列を引数として渡しており、戻り値は平文になります。

この改変により、オンメモリで復号を行うようになるため、動作内容は変わりませんが、クラスファイルやバイナリには平文がそのまま載るようなことはなくなります。バイナリから文字列を探す stringsコマンド や、逆アセンブラや逆コンパイラが持つ文字列参照は使えなくなります。同様に、デコンパイルしたソースコードに対する文字列検索やgrepも使えなくなるので、一目見てわからない程度 (異論は認める) には読みづらくなっているはずです。

ここで一つの疑問があります。復号処理をどう実装すればよいのかという問題です。

前回のエントリの冒頭では 「復号するには復号処理(と鍵)が必要」 だが 「復号処理(と鍵)をどこに隠したらよいかという問題は解決できない」 と記しました。

この例に全く同じ問題が存在します。 d.e("Sndjl&|kr'")d.e("Nhsekoa$Diaa") を眺めると、文字列を平文に戻す関数は d.e() であることは容易に類推できます。

早速該当処理を眺めてみましょう。

public class d {
  // ...
  
  public String e(String d) {
    
    byte e[] = d.getBytes();

    for (int i = 0; i < e.length; i++) {
      e[i] = (byte)(e[i] ^ (0x07));
      e[i] = (byte)(e[i] ^ (i & 3));
    }

    return new String(e);
  
  }
  
  // ...
}

何でしょうかこれは。

XORによるシンプルな暗号化処理ですが(XORだけなので2回かけると元に戻る)、これを暗号化と言ってよいのでしょうか。少しコードが読める方であれば、暗号化の仕組みは容易に理解できるでしょうし、逆コンパイルリストから暗号文を取り出して、さらに平文に戻すスクリプトを作るのも大した手間ではないでしょう。

これは筆者が見本として示した自作のサンプルなので「もう少し頑張れよ」とか「これは難読化とは呼べない」という声も聞こえそうです。明言はしませんが、商用の難読化ツールにおいても五十歩百歩なレベルの実装が見られます。

この例だと、文字列を参照する度に本来のプログラムには存在しないはずの復号処理が走るので、CPUへの負荷が(程度問題ですが)増大します。文字列参照は頻繁に起こるものなので、複雑で重い暗号化・復号アルゴリズムは採用できないという事情もあります。

しかし、難読化ツールが付加した文字列を復号する関数が d.e("[暗号文]") これ1通りしか存在しないのであれば問題です。そうなると「暗号文ある所に d.e() あり」になってしまいます。d.e(" あたりで文字列検索なりgrepなりをかけると、暗号文を列挙することができるでしょう。平文に戻すスクリプトを作るのも大した手間ではないでしょう。

解析のハードルを上げたいのであれば、文字列の暗号化・復号アルゴリズムを100種類用意するとか、暗号化・復号関数を自動生成(なんだかポリモーフィック型のマルウェアみたいだ)するなどして数を増やして、文字列の数だけ異なる暗号化(と復号処理)をかけるしかなさそうですが、単に足し算で(=ひらすら泥臭く汗をかいて)ハードルを上げているだけなので、決定打というよりは嫌がらせという表現の方がしっくりくると思います(難読化の本質ですが...)。

無意味な処理の追加

本来の処理が変わらない範囲で、無意味な命令を追加して回りくどい処理にすることで、人間視点で読みづらくすることを狙った手法があります。スパゲッティコードを人力ではなく機械的に作り出すようなもの、と言い換えてもいいでしょう。これは、コンパイラが行う 最適化 と対立する技術でもあります。

ビット演算の置き換え

例えば、単純なビット演算をストレスフルな記述にする方法があります。

ビット演算 回りくどいビット演算
a = b & c a = (bˆ !c) & b
a = b | c a = (b&c) | (bˆ c)
a = b ˆ c a = (!b&c) | (b&!c)

参考:Obfuscator-LLVM -- Software Protection for the Masses
https://crypto.junod.info/spro15.pdf

この程度なら、「人間にとって理解しにくくなるが、CPUにとっては(ビット演算は低コストの命令であるため)理解しやすいまま」という難読化の本来の狙いと合致している ...ような気がします。

フローの複雑(?)化

(実行可能ファイルを実行可能なまま暗号化する)パッカーとしては古参の部類に入る PELock のウェブサイトには、x86のアセンブルリストに対する難読化のデモがあります。サンプルがいくつかあるので読んでみると、単に命令単位で分割してJMP命令(無条件分岐)だらけにしているだけのものがあります。

参考:Obfuscator for Assembler Source Code
https://www.pelock.com/obfuscator/

  • before (元のコード)
	mov	eax, 1 ; eaxレジスタに1を代入
	push	eax    ; eaxレジスタの値をスタックにプッシュする

上記のコードは、eaxレジスタに1を代入して、eaxレジスタ値をスタックにプッシュする簡単な命令です。これを複雑(?)にすると以下の通りになります。

  • after (ノンリニアなフローに改変されたコード)
	jmp	lbl1   ; (1)
lbl2:
	push	eax    ; (4) eaxレジスタの値をスタックにプッシュする
	jmp	lbl3   ; (5)
lbl1:
	mov	eax, 1 ; (2) eaxレジスタに1を代入
	jmp	lbl2   ; (3)
lbl3:
                      ; (6)

命令単位で分割して、無条件分岐を差し込んで順番を入れ替えて、ノンリニア(非直線的)なフローにしたのが上記のコードです。カッコ内の数字は命令の実行順です。非常に古典的であり、人力難読化でも見られる手口です。

これを難読化に含めて良いのかは個人的には疑問ですが、PELockのウェブサイトでは Obfuscated code とのことなので紹介しました。

制御フローの複雑化

前述した無条件分岐よりも、条件分岐が多いコードほど読みづらいものになります。ソースコードを上から下に読むだけでなく、どういうケースで処理が分岐するか(しないか)も考えなければならないからです。そんなこと解ってるって? そうですね。

無駄な分岐を増やす

前述した無条件分岐の代わりに、条件分岐(if/for/switch/whileなど)を使う方法もあります。

とはいえ、元の処理を改変する訳にもいかないので、常に真(または偽)となる条件式にすると、条件分岐なのに無条件分岐の扱いになるので、前述した「ノンリニア(非直線的)なフロー」を実現できるとともに、絶対実行されない分岐にダミーコードを入れる余地が生まれます。

void hogehoge(String x) {
  
  int y = 123, z = 12;
  
  // 各種変数yやzなどをややこしく操作している処理
  
  if (y == z) { // 常に真or偽になる? 真にも偽にもなる?
    verifyMessage(x);
  } else {
    verifyMessageDummy(x);
  }
  
}

「条件式が常に真(または偽)になる、もしくは真にも偽にもなる」ことが一見してわかりにくくなるほど、カモフラージュの効果が期待できます。たぶん。

他には、構造化プログラミングに反する構造に変える方法があります。goto文を多用したプログラムは人間視点で読みづらく、保守性も下がるので、ダイクストラ先生は50年以上前から安易なgotoの使用に懸念を示していました。

意図的にgoto命令ばかりに改変されたプログラムをデコンパイルしようとすると、構造化プログラムの要件を満たしていなければ、ソースコードとして表現できないコードになるため、デコンパイルに失敗するかもしれません。無理やりデコンパイルしようとすると、gotoと(gotoの飛び先となる)ラベルばかりになるかもしれません。

問題点としては、分岐予測を行う処理系では、パフォーマンスに影響が出る恐れがあるため、速度に対してシビアな処理に対しては使えない、ということになります。

例外の使用

gotoと例外(try/catch)はよく比較されますが、どちらも分岐が複雑になりがちなエラー処理のためによく使われます。

プログラムがエラーなく動いていれば、例外はそう頻繁に発生しないはずですが、try/catchで意図的に例外を発生させると、処理の流れが追いにくくなると期待されています。

これが例外クラスになると、ただえさえ継承関係が複雑だったり、どの例外クラスのインスタンスなのかわかりにくいコードは平然と存在するので、この性質に着目すれば制御構造は複雑になると期待できそうです。

参考:Java Obfuscation Techniques
http://www.sable.mcgill.ca/JBCO/examples.html#RIITCB

問題点は、やはり計算コストです。

何度も述べたことですが、難読化は無駄な処理を挿入する性質上、パフォーマンスが悪化する可能性があります。本来の処理と変わってしまったり、高いパフォーマンスが要求される箇所に無理に入れ込んで処理が間に合わなくなってしまうのは、プロダクトとして見た場合は論外です。

しかし、プロダクトとして見なければ、程度問題と割り切ることもできます。マシンスペックに余裕があれば、多少のパフォーマンスを犠牲にしてでも、消費電力を犠牲にしてでも、より複雑な難読化技術を盛り込むことができる...

そう考えられていた時代もありました。

...

エントリが長くなったので、中編はここまで。

後編では、商用のクラック対策ツールで使われる高度な難読化技術や、マルウェアが自身の検知をすり抜ける目的で使う難読化への対抗策などを概観して終わりにしたいと思います。

メルマガ読者募集 採用情報 2020年卒向けインターンシップ

月別