Rustのwasmを逆コンパイルして丸裸にされるのを難読化で抗う

  • 09 January 2022
Post image

 WebAssembly(wasm)が世の中に登場して久しいが、今回はこのWasmのDecompile(逆コンパイル)の話。
 聞くところによるとWasmは、不正にマルウェアの稼働など悪用に使われていることが多いのだとか。またwasmはJavascriptに比べてマシンパワーを効率的に使うことができるタスクがいくつかあるが、2022年現在では未だブラウザ内のほとんどのDOM操作、UI操作がJSの処理速度に劣っているとも言われているので、結局将来的にwasmも消えていく可能性もある。


WebAssembly(wasm)にセキュリティ情報を含めて良いか

 コンパイル後のwasmファイルはバイナリデータであるが、当然デコンパイルして人間が理解できそうなコードに戻すことができる。なので、重要なシークレットキー的なものをwasmの中に入れてしまうと抜き出されてしまう可能性がある。なので、結論から言うと 重要な情報(dbの接続情報、他のサービスへのシークレットキーなど)は絶対にwasmに含めてはいけない
 一方、別に抜き取られてもそれだけで何か被害があるわけではなく、できれば知られたくないような情報ってこのwasmに隠せないかなと思ったわけ。例えば認証無しでコールできる公開APIの接続キーとか。もちろんブラウザのインスペクターでNetworkを見ると接続時のキーなどは見えてしまうけど、wasmでワンタイムトークンを発行しさらに暗号化したものをHTTPヘッダーに付与して送信するとかにしたらどうだろう?APIへの接続キーを自由に生成するにはwasmを逆コンパイルして中身を解析しないと分からず、このコストが高ければ高いほどハッカーはあきらめてくれる可能性が高い。
 ということで今回はRustによるWebAssembly(wasm)のデコンパイルと文字列の難読化について調査してみた。


Rustでのwasmのコード

 今回は以下のコードでwasmをコンパイルして使ってみる。

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn secret(
    key: &str
) -> String {
    if key != "this is pass!" {
        return String::from("error");
    }
    return String::from("success")
}

 バカみたいなコードなんだけど、外から与えるkeyが"this is pass!“という文字列なら成功、そうでないならエラーが返る"secret"というwasmの関数となる。wasmではJavaScriptからコールされ、jsに値を返すことになるけど、このとき、secretという名前の関数はjs側で使われることになるため関数名はバレてしまう

// JSでのwasm呼び出しで関数名はバレる
import init, {secret} from "./pkg/secret_wasm.js";
init().then(() => {
  secret(input)
});

一方"this is pass!“っていう文字列はwasmファイルの中でバイナリとして表されているので、JSのソース内やブラウザの機能をいくら使っても出てこない。


RustのWasmを逆コンパイルして丸裸にする

 デコンパイラーには以下を使用した。
https://github.com/WebAssembly/wabt

 まずはwat形式にdecompileする。

 # 一部抜粋
  (func (;40;) (type 1) (param i32 i32)
    nop)
  (table (;0;) 2 2 funcref)
  (memory (;0;) 17)
  (global (;0;) (mut i32) (i32.const 1048576))
  (export "memory" (memory 0))
  (export "secret" (func 8))
  (export "__wbindgen_add_to_stack_pointer" (func 30))
  (export "__wbindgen_malloc" (func 12))
  (export "__wbindgen_realloc" (func 14))
  (export "__wbindgen_free" (func 22))
  (elem (;0;) (i32.const 1) func 40)
  (data (;0;) (i32.const 1048576) "this is pass!errorsuccess\00\00\00\04"))

 うん。おもいっきり"this is pass!“や"success”、“error"が見えるね。また、wasmファイルをc言語に逆コンパイルすることもできる。

/*一部抜粋*/
static void w2c_secret(u32 w2c_p0, u32 w2c_p1, u32 w2c_p2) {
  u32 w2c_l3 = 0, w2c_l4 = 0;
  FUNC_PROLOGUE;
  u32 w2c_i0, w2c_i1;
  w2c_i0 = w2c_p2;
  w2c_i1 = 13u;
  w2c_i0 = w2c_i0 == w2c_i1;
  ....
}

static const u8 data_segment_data_0[] = {
  0x74, 0x68, 0x69, 0x73, 0x20, 0x69, 0x73, 0x20, 0x70, 0x61, 0x73, 0x73,
  0x21, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73,
  0x73, 0x00, 0x00, 0x00, 0x04,
};

 secret関数が見えるのと、たどっていくとdata_segment_data_0ってのが発見できる。ここに列挙されている16進数はASCIIコードを表すことが容易に想像できる。このASCIIコードをアルファベットに戻すと、"this is pass!errorsuccess“になる。そしてこのdata_segment_data_0定数を使っている箇所をみるとそれぞれの文字列のindexが指定されていたので、Rustコード内の文字列は丸わかりってことだ。ただデコンパイル後のc言語を見ても、処理のロジックを理解するのはかなり難しい印象だった。今回のような非常にシンプルなコードでさえ難しいのだから実際のあれこれ処理するようなwasmの処理をデコンパイルして理解するのはかなりのコストがかかりそうだ。


難読化すると服は脱がされないか

 今回はRustのobfstrというのを使用してコードの難読化をしてみる。いたって簡単で以下のように使用できるようだ。

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn secret(
    key: &str
) -> String {
    if key != obfstr::obfstr!("this is pass!") {
        return String::from("error");
    }
    return String::from("success")
}

 “this is pass!“の部分に難読化を当ててみた。これでコンパイルして戻すと以下のようになる。

 # 一部抜粋
  (export "secret" (func 8))
  (export "__wbindgen_add_to_stack_pointer" (func 30))
  (export "__wbindgen_malloc" (func 12))
  (export "__wbindgen_realloc" (func 14))
  (export "__wbindgen_free" (func 22))
  (elem (;0;) (i32.const 1) func 40)
  (data (;0;) (i32.const 1048576) "errorsuccess\ce6\bd\a1{\1c\16YL\c8r]n\00\00\00\04"))

 wat形式だと、難読化していないerror,successとなり、難読化したpassは意味不明な文字列になっている。

/*一部抜粋*/
static const u8 data_segment_data_0[] = {
  0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
  0xce, 0x36, 0xbd, 0xa1, 0x7b, 0x1c, 0x16, 0x59, 0x4c, 0xc8, 0x72, 0x5d,
  0x6e, 0x00, 0x00, 0x00, 0x04,
};

 c言語にデコンパイルしても、このASCIIコードは”errorsuccessÎ6½¡{YLÈr]n“となる。確かに難読化されている。

 ちなみにこの難読化された文字列を復元することは可能だ($crate::bytes::deobfuscate参照)。この難読化のルールがばっちりcrateのソースに書かれていたので、おそらく凄腕ハッカーたちはこの難読化方法にたどり着くことができ、たやすく複合してしまうのだろう。なので難読化しても丸裸にすることは可能ってことだ。


もっともっと難読化してみる

 次のように文字列を分解してString型を絡めたり、一部だけ難読化するなどしてみるとどうか。

#[wasm_bindgen]
pub fn secret(
    key: &str
) -> String {
    let this: String = String::from("this");
    if key != this + obfstr::obfstr!(" is pass!") {
        return String::from("error");
    }
    return String::from("success")
}

 そして以下がwasmをcにデコンパイルしたものの一部。

static const u8 data_segment_data_0[] = {
  0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
  0x2f, 0x23, 0x99, 0xb1, 0x6a, 0x6c, 0xa0, 0xbe, 0x43, 0x00, 0x00, 0x00,
  0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
  0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00,
  0x04, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x63, 0x61, 0x6c, 0x6c,
  0x65, 0x64, 0x20, 0x60, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x3a,
  0x75, 0x6e, 0x77, 0x72, 0x61, 0x70, 0x28, 0x29, 0x60, 0x20, 0x6f, 0x6e,
  0x20, 0x61, 0x20, 0x60, 0x4e, 0x6f, 0x6e, 0x65, 0x60, 0x20, 0x76, 0x61,
  0x6c, 0x75, 0x65, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x2f, 0x73, 0x74, 0x64, 0x2f,
  0x73, 0x72, 0x63, 0x2f, 0x70, 0x61, 0x6e, 0x69, 0x63, 0x6b, 0x69, 0x6e,
  0x67, 0x2e, 0x72, 0x73, 0x6c, 0x00, 0x10, 0x00, 0x1c, 0x00, 0x00, 0x00,
  0xeb, 0x01, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00, 0x6c, 0x00, 0x10, 0x00,
  0x1c, 0x00, 0x00, 0x00, 0xec, 0x01, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00,
  0x06, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00,
  0x07, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
  0x08, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00,
  0x0a, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00,
  0x04, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
  0x08, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00,
  0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x2f, 0x61, 0x6c, 0x6c, 0x6f,
  0x63, 0x2f, 0x73, 0x72, 0x63, 0x2f, 0x72, 0x61, 0x77, 0x5f, 0x76, 0x65,
  0x63, 0x2e, 0x72, 0x73, 0x63, 0x61, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79,
  0x20, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x00, 0x00, 0x00,
  0xf0, 0x00, 0x10, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x18, 0x02, 0x00, 0x00,
  0x05, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x01, 0x00, 0x00, 0x00, 0x10,
};

 これがどのようなルールで難読化されたものかを突き止めるのは、かなり難しいのではないか。またRustの時点でStringだけでなくbyteコードを一部使用したりn文字ずつobfuscateを繰り返すとかすると、正直元の文字列に戻すのは不可能なのではと思ってしまう。おそらくとんでもないハッカー達はデコンパイルした言語の処理の方を見て、難読化のルールを突き止めることができるのだろうけど。しかしものすごい時間がかかるだろう。


Wasmの難読化は十分対策になりうる

 上記のとおりwasmの難読化は、ごく一部の天才ハッカー以外に対して十分有効と思われる。さらにこのwasmを読み込むJSをwebpackで難読化コンパイルしてさらに隠ぺいを追加することも可能だ。
 ただし逆コンパイルしてあらゆる処理を丸裸にされないこと、中に書かれていた文字列を複合されないようにすることは絶対不可能だ。どこかのいやらしいハッカーが時間をかければwasmを全裸にすることはたやすいと思われる。なので万が一にも漏れてしまってはいけないような情報をWasmのソースに含めてはいけない

 一方でハッカー(クラッカー)も暇人ではないので、費用対効果は絶対に考慮する。つまりできる限りの難読化を行って、たった少しのヒントを得るために大きな時間的コストをかける必要があるのに、得られるリターンがほぼないと判断すればターゲットから外れることだろう。要は家の鍵やチェーンロックも時間や道具をかければ解除することができるけど、みんな2つカギをかけたり、チェーンロックを追加したりして家の安全を守っていることと同じだ。一切鍵はかけませんっていう家が泥棒に圧倒的に狙われやすいのと同じで、簡単にハックできそうなシステムが狙われるってこと。

You May Also Like

フィッシング詐欺サイトに大量のゴミ情報をプレゼントできるか

フィッシング詐欺サイトに大量のゴミ情報をプレゼントできるか

 ここ2年ほど スミッシング詐欺(=SMSフィッシング) のメッセージを何通か受けたことはないだろうか?Amazonとか銀行からSMSのメッセージが届き、そこにURLが書かれていてタップすると詐欺サイトに飛ばされるあれのことだ。まぁ普通に若い人はあんなバカな詐欺に引っかかるとは思えないけど、飛んでく …

ロボットで「私はロボットではありません」を突破してみる

ロボットで「私はロボットではありません」を突破してみる

 ふと何気なくyoutubeを見ていたら以下の動画にたどり着いた。内容は「recaptcha v2」の 「私はロボットではありません」のセキュリティチェックがロボットにとって最も難しいと説明されていた。  いや、そんなわけあるか!しかもこの動画は"2021/04/13"に公開 …