マルウェアの自己改変を分析するツール「memwatch」を開発した話
ChillStack セキュリティ部の一瀬です。
以前下記記事にて、Frida の解析からアプリを保護する「Anti-Frida」技術について紹介しました。
当社セキュリティ部では、上記ブログ記事に関連する研究成果の一つとして、「Frida による動的関数呼び出し追跡の検出および回避手法の Android 環境への拡張検討」という論文を情報処理学会 第 87 回全国大会にて発表しました。
本記事では、研究用に開発したツール「memwatch」を用いて、情報処理学会にて発表した内容を追実験しながら、論文内のコアであるseccompとptraceの二つを組み合わせた解析手法を紹介します。
- 論文概要
Frida による動的関数呼び出し追跡の検出および回避手法の Android 環境への拡張検討 - PoC:Github プロジェクト
memwatch
【体験】memwatch を使ってみよう
memwatch は、マルウェア等のコード領域に対する自己改変を検出/無効化するツールです。
ここでは、Frida による解析対策としてコード改竄検知処理を実装したアプリを memwatch で分析する実験を行います。
では、実際に動かしてみましょう。
memwatch 関連の各種ファイルは下記からダウンロードできます。
Github Release ページ
実行の準備
環境
- root 化された Android ARM64端末(実機または Android エミュレータ)
- PC と Android 端末を接続した上で、 adb コマンドが実行できる状態
- PC 上に Frida をインストール済み
※ adb や Frida のセットアップ方法については、ここでは割愛します。
※ 現時点で ARM64 のみ対応しており、x86_64 環境では動作しません。
セットアップ手順
まず、memwatch の実行ファイルを Android 端末に転送し実行権限の付与を行います。
adb push memwatch /data/local/tmp
adb shell chmod a+x /data/local/tmp/memwatch
次に、テスト用アプリ「hook_detector」のインストールを行います。
adb install app-release.apk
そして、frida-server を起動します。
adb shell
$ su
$ cd /data/local/tmp
$ ./frida-server
動かしてみよう
対象のアプリには、コード領域の改竄を検出した場合にオリジナルの命令に復元する改竄対策が実装されています。
このアプリはAnti-Frida等のフッキング対策が実装されたマルウェア等を想定して作成しました。
検証用アプリのため、libc.soのopen関数の改竄のみ検知する仕様になっています。
Frida 単体でフックした場合と、memwatch を併用した場合を比較してみます。
パターン 1: Frida フックのみ(memwatch なし)
このパターンでは、Frida による open 関数のフック後に、hook_detector によって検出されます。
- Frida でフックを適用して アプリを実行:
frida -U -f com.chillstack.hook_detector -l open_hook.js
まずアプリ起動後に、「Start Detection」ボタンを押します。
下記のようにフックは検知されます。
また「Fix Hook」ボタンを押すことによってコードはオリジナルに書き戻され、Frida の Interceptor.attach は無効化されます。
パターン 2: Frida フック + memwatch
このパターンでは、memwatch の機能を使って、対象アプリの改竄保護メカニズムを解析・制御します。
- memwatch を起動:
adb shell
$ su
$ cd /data/local/tmp
# スキップモード(自己書き換えを無効化)
$ ./memwatch -s
- Frida でフックを適用して アプリを実行:
frida -U -f com.chillstack.hook_detector -l install_seccomp.js -l open_hook.js
先程と同じように「Start Detection」ボタンを押すと、下記のようにフックは検知されます。
ただし今回は「Fix Hook」ボタンを押してもコードは改竄された状態のままで、Frida の Interceptor.attach は引き続き有効になっています。
memwatch がどのようにしてコード領域に対する自己改変を解析/無効化しているのかの説明のために、次項ではまず seccomp と ptrace についてご説明します。
【解説】seccomp と ptrace について
seccomp 利用例 : openat システムコールのブロック
seccomp は Linux カーネルの重要なセキュリティ機構で、プロセスが実行可能なシステムコールを制限します。具体的には以下の特徴があります:
※ サンプルコードはLinux x86_64またはARM64環境でコンパイル可能です。
seccomp の動作モード
- SECCOMP_MODE_STRICT: 最も厳格なモードで、read()、write()、exit()、sigreturn()のみ許可
- SECCOMP_MODE_FILTER: BPF (Berkeley Packet Filter) ルールを使用してシステムコール単位で詳細な制御が可能
下記は BPF ルールを用いて、openat システムコールの呼び出しをブロックするコードとなります。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/syscall.h>
#define BPF_STMT(code, k) { (unsigned short)(code), 0, 0, k }
#define BPF_JUMP(code, k, jt, jf) { (unsigned short)(code), jt, jf, k }
int main() {
struct sock_filter filter[] = {
// 現在実行されているシステムコールの番号を読み取る
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
// システムコールがopenatかチェックする
// もしそうなら次の命令へ(0)、違うなら1つ先の命令へジャンプ(1)
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 0, 1),
// openatだった場合の処理:「アクセス拒否」エラーを返す
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)),
// openat以外のシステムコールはすべて許可する
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)
};
struct sock_fprog prog = {
.len = static_cast<unsigned short>(sizeof(filter) / sizeof(filter[0])),
.filter = filter
};
// No new privileges
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
// seccompモードをフィルタモードに設定
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
// seccompによってopenatの呼び出しはブロックされる
int fd = openat(AT_FDCWD, "/etc/hosts", O_RDONLY);
if (fd < 0) {
std::perror("openat失敗");
} else {
std::cout << "openat成功" << std::endl;
close(fd);
}
return 0;
}
上記をsample1.cpp
として保存します。
$ g++ -o sample1 sample1.cpp
$ chmod a+x sample1
$ ./sample1
openat失敗
上記のように、seccomp を用いることで対象のシステムコールをピンポイントに制御することが可能となります。
ptrace の利用例 : 子プロセスのレジスタの値を読み取る
ptrace はプロセスの挙動を監視・制御するためのデバッグ用のシステムコールです。
gdb などのデバッガはこの機能を利用しています。
ptrace の主な機能として下記があります。
- 対象プロセスのシステムコール呼び出しの検出
- レジスタ値の読み書き
- メモリの読み書き
- シグナルの制御
下記は ptrace を使って子プロセスのレジスタを読み取るサンプルコードです。
#include <iostream>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/uio.h>
#include <elf.h>
#include <signal.h>
int main() {
pid_t child = fork();
if (child == 0) {
// 子プロセス
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
raise(SIGSTOP);
return 0;
} else {
int status;
waitpid(child, &status, 0); // 子の SIGSTOP を待つ
#if defined(__x86_64__)
// ─── x86_64 用 PTRACE_GETREGS ────────────────────────────
struct user_regs_struct regs64;
if (ptrace(PTRACE_GETREGS, child, nullptr, ®s64) < 0) {
perror("ptrace(GETREGS)");
return 1;
}
std::cout << "RAX: 0x" << std::hex << regs64.rax << std::dec << std::endl;
std::cout << "RBX: 0x" << std::hex << regs64.rbx << std::dec << std::endl;
std::cout << "RCX: 0x" << std::hex << regs64.rcx << std::dec << std::endl;
#elif defined(__aarch64__)
struct user_regs_struct regs;
struct iovec iov;
iov.iov_base = ®s;
iov.iov_len = sizeof(regs);
if (ptrace(PTRACE_GETREGSET, child, (void*)NT_PRSTATUS, &iov) < 0) {
perror("ptrace(GETREGSET)");
return 1;
}
// x0–x2 を例示
std::cout << "x0: 0x" << std::hex << regs.regs[0] << std::dec << std::endl;
std::cout << "x1: 0x" << std::hex << regs.regs[1] << std::dec << std::endl;
std::cout << "x2: 0x" << std::hex << regs.regs[2] << std::dec << std::endl;
#else
# error "Unsupported architecture"
#endif
// 子を再開
ptrace(PTRACE_CONT, child, nullptr, nullptr);
waitpid(child, &status, 0);
return 0;
}
}
$ g++ -o sample2 sample2.cpp
$ chmod a+x sample2
$ ./sample2
# 出力例:
RAX: 0x: 0
RBX: 0x: 42
RCX: 0x: 19
上記のように、ptrace を用いることで対象プロセスの様々な情報にアクセスすることが可能です。
続いて、seccomp と ptrace を組み合わせた分析を紹介します。
seccomp と ptrace を組み合わせた分析
seccomp と ptrace を組み合わせることで、プロセスのシステムコール監視と制御に関する強力な機能が実現できます。
主なメリットは以下の通りです。
- 監視と制御:特定のシステムコールを検出し、そのシステムコールの引数を検査できます。例えば openat システムコールが特定のファイルにアクセスしようとしているかを確認できます。
- 動的な判断:ただ単にシステムコールをブロックするだけでなく、その引数に基づいて動的に許可/拒否を判断できます。例えば、特定のディレクトリへのアクセスだけを許可するなどのポリシーを実装できます。
- 監査とログ記録:システムコールとその引数を記録することで、プロセスのアクティビティを監視し、セキュリティ監査に役立てられます。
- 透過的なサンドボックス:アプリケーションを変更せずに、外部からその挙動を制限できます。
seccomp はシステムコールをフィルタリングする機能を提供し、ptrace はプロセスの実行を一時停止して状態を検査・変更する機能を提供します。両者を組み合わせることで、セキュリティ監視やサンドボックス環境の構築などの高度な用途に活用できます。
分析の実装手法
-
seccomp フィルタの設定
a. 監視対象プロセスに seccomp フィルタをインストール
b. 特定のシステムコール(例:ファイルアクセス、ネットワーク通信など)を検出するルールを定義
c. 検出時の動作として BPF でSECCOMP_RET_TRACE
を指定し、監視プロセスに通知BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRACE)
-
ptrace による状態検査
ptrace で対象プロセスにアタッチした後、下記オプションを設定することで上記のSECCOMP_RET_TRACE
の通知を受け取るように設定可能です。ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESECCOMP);
a. システムコール発生時に監視プロセスがレジスタと引数を検査
b. 引数のアドレスから対象プロセスのメモリ内容を読み取り
c. システムコールの詳細情報(ファイルパス、ソケット情報など)を取得
上記のような実装により、例えば openat システムコールの読み込み先のファイル名を取得し監視や制御が可能となります。
【考察】memwatch の詳細
memwatch は下記開発背景にある、先行研究の手法を改善するために開発を行いました。
開発背景
先行研究について
先行研究の概要としては Linux を対象として Frida によるフッキングの検知手法と、更にその検知手法を詳細に分析する手法を提案しています。
先行研究の論文:
Detecting and Bypassing Frida Dynamic
Function Call Tracing: Exploitation and
Mitigation∗
論文のオープンソース実装:
gopper
先行研究の課題
先行研究では、主に以下の 2 つの手法で Frida によるフッキングの検知手法を提案しています。
-
soft dirty-bitという Linux のページテーブルに存在するフラグの監視
メモリ書き込み時にフラグが立つのを利用して、書き込みを検知 -
mprotectシステムコール呼び出し時のメモリ属性変更フラグを監視
.textセクション等のコード領域は基本的に書き込み不可能なため、mprotect でコード領域を書き込み可能に変更する試行を検知
課題点
上記 2 つの手法は、ともに一定間隔のポーリング操作で検知する方法であったためリアルタイムに改変を検知する事が出来ませんでした。
また、改変の分析は行えても完全な無効化までは行えていませんでした。
動作の仕組み
memwatch の仕組みは、先項で紹介した手法とデバッガの例外ハンドリング機能を組み合わせたものとなります。
- seccomp で mprotect システムコールの呼び出しを監視する
- mprotect システムコール呼び出し時にデバッガに通知を送る
- デバッガで mprotect の対象アドレス、変更先メモリ属性を取得する
- 3.の時に、条件(コード領域に対する変更等)により書き込み属性のみ剥奪する
- 4.により、コード領域に書き込み試行時に SIGSEGV 例外が発生する
- デバッガにより、SIGSEGV 例外を捕捉し適切にハンドリングを行う
課題と展望
まず、論文の PoC として開発したものというのもあり現時点で汎用的に利用できるツールとしてまでは開発を行えていません。
また ptrace を用いること等から、アンチデバッグ機構に阻害され解析が出来ないことも想定されます。
Linux 対応や ARM64 アーキテクチャ以外への拡張を含め、更なる手法の改良を研究していきたいと思います。
※現在も鋭意開発中であり、UI 上で分析する機能も実装しています。
まとめ
今回 seccomp と ptrace という普段業務では余り触れない低レベルな機能を組み合わせて新しい解析手法を考える過程は、とても学びがありました。
また、先行研究の課題に対して新たな改善策を実装できたのは、研究としての醍醐味を感じられる部分でした。
今後も今回の研究に留まらず、情報セキュリティ分野に貢献できるよう研鑽を続けていきたいと思います。
最後に、ChillStack では現在エンジニアを積極採用中です!
「今は転職する気はないけど、ちょっと興味を持ったので話を聞いてみたい」という方も歓迎ですので、ご興味ある方お待ちしてます!
-
公式 Note
https://note.com/chillstack
Discussion