LoginSignup
2
2

Linux カーネルを改造してプログラムの動的解析システムを作った話

Last updated at Posted at 2023-03-28

こんにちは!
naru-99 です。

僕の卒研では、Linux のカーネルソースを改造し、プログラムを動的解析するシステムを構築しました。
カーネルソース改造の際には、僕が調べた限りではインターネットに情報がない or 古くて役に立たないことが結構ありました。
結果的には、カーネルソースを読み込んで自力でどうにかしましたので、そのことについてまとめたいと思います。

間違っていたり稚拙な部分が多いとは思いますが、ご容赦いただきますようよろしくお願いします。

目次

  • はじめに
  • Linux カーネルソースで UDP 通信を行う方法(TCP も合わせて紹介)
  • 排他処理(spinlock・mutex)
  • カレントプロセスの特定
  • システムコールの情報を UDP 通信を用いて送信する
  • PID とその PPID を UDP 通信を用いて送信する
  • Makefile の記述方法
  • 必要な情報のみに選別する
  • おわりに

はじめに

僕の卒研では、複数種類(プログラミング言語だけでなく、実行可能バイナリなど)のマルウェアを一意に解析したいという目的で、プログラムを動的解析するシステムを構築しました。
Windows はソースコードが公開されてないので、Linux でやりました。
(Linux でマルウェアが増えているのはかなりの棚ボタ)

僕がソースコードを改造した部分は torvalds 氏のリポジトリにあるソースの部分だけであり、ディストリビューション依存の部分には触れていません。
ですので、様々なディストリビューションに理論上は対応可能だと考えられます。

また、ソースコードは下記のレポジトリにあげておきました。

https://github.com/naru-99/sct_debian

実験環境

  • Windows 10 (以降 Host OS)
  • Oracle Virtual Box バージョン 6.1.36
  • debian 11.6 (以降 Guest OS)

↑ VirtualBox を用いて仮想環境を構築し、同環境をサンドボックス環境として利用します。この仮想環境の中でプログラムを実行します。

動的解析で取得するデータについて

僕の動的解析システムでは、システムコール ID とその呼び出し元 PID、PID の親子関係についてのデータを取得します。

  • システムコール

    syscall.png

    メモリ領域における、OS が存在する領域がカーネル空間(ここのプログラムを改造)、カーネル空間以外をユーザ空間といいます。
    システムコールは、ユーザ空間のプログラムがカーネルの機能を使うための唯一の API(関数みたいなやつ)です。
ID Name Description
0 read ファイルを読み込む
57 fork プロセスを生成する
59 execve プログラムを実行する

システムコールの使用例として、CMD から Python ファイル(sample.py)を実行するときは次のようになります。
syscall_example.png

つまり、「CMD から Python を実行する」という実行の内容は、fork → execve → read というシステムコールのシーケンスに変換されたと言えます。
(注意:上記の例で呼び出されるシステムコールは 3 つだけでなく、ほかにも様々なシステムコールが呼び出されています。)
とゆうことで、この「システムコールのシーケンス」を「プログラムの実行の内容」を表すデータとし、機械学習に入力したいと考えました。

  • プロセス ID(PID)

    プロセス:実行中のプログラムのことで、プロセスには他のプロセスとは独立な PID が割り振られます。
    ユーザ空間のプログラムは実行に際し、システムコールを呼び出すことから、システムコールには呼び出し元のプロセスがあります。
    実験環境では、バッググラウンドプロセスなどの、解析対象のプログラムとは別のプログラムも動作していることから、PID で識別してやる必要があります。


    Get_SubTree.png

    また、プロセスは他のプロセスを複製して生成されるため、複製元プロセス(親プロセス)と複製先プロセス(子プロセス)という親子関係が存在します。ある親プロセスが解析対象の場合、その子プロセス(およびその子孫すべて)も解析対象となります。したがって、親子関係の木構造があった場合、解析対象のプログラムを実行し始めたプロセスを根とするサブツリーのプロセスすべてが解析対象のプロセスになります。

ということで、

  • システムコール ID とその呼び出し元の PID を併せて取得する
  • PID の親子関係を取得し、解析対象の呼び出したシステムコールのみに選別する

    ということをします。

動的解析システムと改造内容の概要

DASystem.png

  • Virtual Box で仮想環境を構築
  • ゲストOSのカーネル空間とホストOSのサーバでUDP 通信を行い、文字列を送信
    • システムコールを実行する関数をフックし、システムコールの ID と呼び出し元の PID を送信
    • プロセスを生成する関数をフックし、生成したプロセスの PID と親プロセスの PID を取得(プロセスの親子関係を取得)

カーネル空間で UDP 通信をする方法

改造箇所と目的

カーネルソースのうち、通信関連のソースは net フォルダにあります。
その中に一つファイルを追加してやり、通信用ソースを記述します。
具体的には、net/sci_client.c というソースファイルと、include/net/sci_client.h というヘッダファイルを追加しました。

また、このセクションでは、最も抽象的な機能として、入力された文字列を UDP 通信を用いて実機環境のサーバーに送信する関数を作成することを目的とします。
また、TCP 通信をする方法についても説明したいと思います。

改造

ソケット作成(TCP、UDP で共通)

ソケットを作成する方法は、システムコールの socket を参考にしました。
ソケットを作成する関数として、sock_create_kern という関数が実装されています。

(net/socket.c)

/**
 *	sock_create_kern - creates a socket (kernel space)
 *	@net: net namespace
 *	@family: protocol family (AF_INET, ...)
 *	@type: communication type (SOCK_STREAM, ...)
 *	@protocol: protocol (0, ...)
 *	@res: new socket
 *
 *	A wrapper around __sock_create().
 *	Returns 0 or an error. This function internally uses GFP_KERNEL.
 */

int sock_create_kern(struct net *net, int family, int type, int protocol, struct socket **res)
{
	return __sock_create(net, family, type, protocol, res, 1);
}

簡潔に説明すると、

  • 第二引数の family はプロトコルファミリ(IPv4、IPv6 など)
  • 第三引数の type は通信方法(データグラム志向(UDP)、ストリーム志向(TCP)など)
  • 第四引数の protocol はプロトコル(UDP、TCP など)

です。こんな感じで UDP ソケットを作成できます。(net/sci_client.c)

sock_create_kern(&init_net, PF_INET, SOCK_DGRAM, IPPROTO_UDP,&(sci_cs_ptr->sock));

PF_INET は IPv4 を指定しています。
最後の引数は socket 構造体をポイントしとくためのポインタ変数を渡しておきます。
また、sci_cs_ptr は sci_client_struct 型のポインタです。
(include/net/sci_client.h)

struct sci_client_struct {
	struct socket *sock;
	struct sockaddr_in addr;
	struct msghdr msg;
	struct iov_iter iov_it;
};

TCP 通信をする場合は次のようになると思います。

sock_create_kern(&init_net, PF_INET, SOCK_STREAM, IPPROTO_TCP,&(sci_cs_ptr->sock));

ソケット接続(TCP 通信のみ)

次に、ソケットを接続します。
ソケットの接続は、システムコールの connect を参考にしました。
ソケットを接続する関数としては、kernel_connect が実装されています。

(net/socket.c)

/**
 *	kernel_connect - connect a socket (kernel space)
 *	@sock: socket
 *	@addr: address
 *	@addrlen: address length
 *	@flags: flags (O_NONBLOCK, ...)
 *
 *	For datagram sockets, @addr is the addres to which datagrams are sent
 *	by default, and the only address from which datagrams are received.
 *	For stream sockets, attempts to connect to @addr.
 *	Returns 0 or an error code.
 */

int kernel_connect(struct socket *sock, struct sockaddr *addr,
                                      int addrlen, int flags)
{
	return sock->ops->connect(sock, addr, addrlen, flags);
}

僕のソースでは、ソケットの作成と合わせてソケットの init 関数として実装しておきました。
(net/sci_client.c)

void init_sci_client(struct sci_client_struct *sci_cs_ptr, int port)
{
	sock_create_kern(&init_net, PF_INET, SOCK_DGRAM, IPPROTO_UDP,
			 &(sci_cs_ptr->sock));

	sci_cs_ptr->addr.sin_family = PF_INET;
	sci_cs_ptr->addr.sin_port = htons(port);
	sci_cs_ptr->addr.sin_addr.s_addr = htonl(sci_server_ip);

	kernel_connect(sci_cs_ptr->sock,
		       (struct sockaddr *)(&(sci_cs_ptr->addr)),
		       sizeof(struct sockaddr), 0);

	sci_cs_ptr->msg.msg_name = &(sci_cs_ptr->addr);
	sci_cs_ptr->msg.msg_namelen = sizeof(struct sockaddr_in);
	sci_cs_ptr->msg.msg_iter = sci_cs_ptr->iov_it;
	sci_cs_ptr->msg.msg_control = NULL;
	sci_cs_ptr->msg.msg_controllen = 0;
	sci_cs_ptr->msg.msg_flags = 0;
}

いろいろややこしいですが、addr の中身を更新している三行は、

  • sin_family:プロトコルファミリのこと(IPv4 なので PF_INET)
  • sin_port:サーバーのポート
  • sin_addr.s_addr:サーバーの IP アドレス

を指定しています。
また、

  • htons は int 型のポート番号をネットワークバイトオーダーに変換するマクロ
  • htonl は unsignd long int 型の IP アドレスをネットワークバイトオーダーに変換するマクロ

です。

そして、msg(メッセージヘッダ)の中身を更新している最後の 6 行のうち、msg_name フィールドは先ほどの addr を渡しておき、あとは他のカーネルソースに従った感じです。
また、UDP 通信でも connect しても問題ないことから、connect しておきました。

この init メゾットをサブルーチン中に一度だけ呼び出すことで、ソケットを段取りします。

また、僕の研究では、Virtual Box のホストオンリーアダプタを用いてゲスト OS のカーネル空間とホスト OS のサーバで通信しました。
その場合のサーバの IP アドレスは、"192.168.56.1"となります。
htonl マクロに入力する時は、16 進数で"0xc0a83801"とします。

文字列を送信する関数

最後に、ソケットから文字列を送信します。
文字列の送信では、システムコールの sendmsg を参考にしました。
文字列を送信する関数としては、kernel_sendmsg が実装されています。
(net/socket.c)

/**
 *	kernel_sendmsg - send a message through @sock (kernel-space)
 *	@sock: socket
 *	@msg: message header
 *	@vec: kernel vec
 *	@num: vec array length
 *	@size: total message data size
 *
 *	Builds the message data with @vec and sends it through @sock.
 *	Returns the number of bytes sent, or an error code.
 */

int kernel_sendmsg(struct socket *sock, struct msghdr *msg,
		   struct kvec *vec, size_t num, size_t size)
{
	iov_iter_kvec(&msg->msg_iter, WRITE, vec, num, size);
	return sock_sendmsg(sock, msg);
}

とゆうことで、この関数を使って次のような関数を作りました。
(net/sci_client.c)

static struct kvec iov;
static DEFINE_MUTEX(sci_send_mutex);
static DEFINE_SPINLOCK(spinlock);
void sci_send(char *buf, int len, struct sci_client_struct *sci_cs_ptr,
	      int is_irq)
{
	if (is_irq) {
		mutex_lock(&sci_send_mutex);
		iov.iov_base = buf;
		iov.iov_len = len;
		kernel_sendmsg(sci_cs_ptr->sock, &(sci_cs_ptr->msg), &iov, 1,
			       len);
		mutex_unlock(&sci_send_mutex);
	} else {
		spin_lock_irqsave(&spinlock);
		iov.iov_base = buf;
		iov.iov_len = len;
		kernel_sendmsg(sci_cs_ptr->sock, &(sci_cs_ptr->msg), &iov, 1,
			       len);
		spin_unlock_irqrestore(&spinlock);
	}
}

spinlock と mutex は排他処理にかかわることで、次のセクションで説明します。
この関数の引数は

  • buf:送信する文字列
  • len:buf の中に格納されている文字列の長さ
  • sci_cs_ptr:struct sci_client_struct のポインタ(ソケットなどが格納されているやつ)
  • is_irq:割り込みコンテキストかどうか(排他処理のセクションで説明)

となっています。

順を追って説明すると、

  • iov(io ベクトル)に送信したい文字列とその長さを格納
  • kernel_sendmsg を使って文字列を送信

ということをしています。
以上で、文字列を送信するための段取りができました。

排他処理

OS というものを相手にしている以上、同じタイミングで同じメモリ領域を使うなんてことを想定してやらないといけません。
特に、システムコールともなると、単位時間当たりですごい回数呼び出されていますので、想定せずにコードを書くと簡単にヌルポが発生します (しました)

ということで、当然対策があります。
それが排他処理(spinlock・mutex)です。
簡潔に説明すると、排他処理の開始する関数と終了する関数で挟まっているコードは、一つのプロセスしか使用できなくなります。
二つ目以降のプロセスが同じタイミングでメモリ上の関数を参照しようとすると、「待っててね」されてしまいます。
その、「待っててね」の仕方の違いが spinlock と mutex の違いです。

  • spinlock
    排他制御を獲得するまで状態変数を操作し続ける
  • mutex
    mutex は排他制御を獲得するまでスレッドをスリープする

つまり、spinlock は cpu リソースを消費しながら「待っててね」し、mutex はスリープして「待っててね」するということです。
注意点として、mutex は割り込みコンテキスト(ハードウェア/ソフトウェア IRQ)で使用不可です。

以上から、次のような使い分けになると思います。

  • IRQ なら spinlock
  • 長時間ロックするなら mutex、そうでないなら spinlock

では、コード説明に入ります。
(net/sci_client.c (再掲))

static struct kvec iov;
static DEFINE_MUTEX(sci_send_mutex);
static DEFINE_SPINLOCK(spinlock);
void sci_send(char *buf, int len, struct sci_client_struct *sci_cs_ptr,
	      int is_irq)
{
	if (is_irq) {
		mutex_lock(&sci_send_mutex); //排他処理開始
		iov.iov_base = buf;
		iov.iov_len = len;
		kernel_sendmsg(sci_cs_ptr->sock, &(sci_cs_ptr->msg), &iov, 1, len);
		mutex_unlock(&sci_send_mutex); //排他処理終了
	} else {
		spin_lock_irqsave(&spinlock); //排他処理開始
		iov.iov_base = buf;
		iov.iov_len = len;
		kernel_sendmsg(sci_cs_ptr->sock, &(sci_cs_ptr->msg), &iov, 1, len);
		spin_unlock_irqrestore(&spinlock); //排他処理終了
	}
}

DEFINE_MUTEX(sci_send_mutex)と DEFINE_SPINLOCK(spinlock)はそれぞれ初期化を行うマクロです。
引数の変数が変数名になります。

このコードでは IRQ コンテキストの時は spinlock、そうでないときは mutex を用いるようにしています。
mutex の場合、mutex_lock()と mutex_unlock()で囲まれている部分が排他処理されます。
また、spinlock の場合は、spin_lock_irqsave()と spin_unlock_irqrestore()で囲まれている部分が排他処理されます。

カーネル内でシステムコールを実行する方法として、int 0x80というintel x86チップセットの命令から、宛先オペランドで指定された割り込みまたは例外ハンドラへの呼び出しを行う方法があり、IRQコンテキストになるため、上記のような対策を取りました。

カレントプロセスの特定

このセクションでは、現在カーネルのコードを呼び出しているプロセスの PID を特定する方法について説明します。
つまり、システムコールの呼び出し元の PID を特定する方法です。
また、PID の親子関係を特定する際にも使用します。

方法は、

pid_nr(get_task_pid(current, PIDTYPE_PID))

とすることです。
これで PID を取得することができます。

少し説明をしておくと、current は現在実行中の PID のプロセスの task_struct(カーネルにおける、プロセスを表す構造体)のポインタを返すマクロです。
その task_struct の中に格納されている PID を取得するのが上記のコードです。

システムコールの情報を送信する

システムコールを実行する関数は、arch/x86/entry/common.c に実装されています。
こんな感じに追記しました。

static int is_sci_client_ready = 1;
struct sci_client_struct sci_cs;
struct sci_client_struct *p_sci_cs;

__visible noinstr void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
	nr = syscall_enter_from_user_mode(regs, nr);
	if (is_sci_client_ready) {
		p_sci_cs = &sci_cs;
		init_sci_client(p_sci_cs, sci_port_syscall);
		is_sci_client_ready = 0;
	}

	instrumentation_begin();
	if (likely(nr < NR_syscalls)) {
		nr = array_index_nospec(nr, NR_syscalls);
		send_pid_scnum((int)pid_nr(get_task_pid(current, PIDTYPE_PID)),nr);
		regs->ax = sys_call_table[nr](regs);
(略)

また、システムコールの ID と呼び出し元 PID を送信するために、次のような関数を作りました。

void send_pid_scnum(int pid, unsigned long scnum)
{
	char sendchar[PACKET_STR_SIZE];
	int len;
	len = snprintf(sendchar, PACKET_STR_SIZE, "%lu%c%d", scnum, SCI_SEPCHAR, pid);
	sci_send(sendchar, len, p_sci_cs,1);
}

説明としては、

  • do_syscall_64 の引数 nr がシステムコールの ID
  • if 文のところはソケットの init を一度だけ実行することを記述
  • sys_call_table[nr](regs)がシステムコールを実行する部分、その直前で情報を送信
  • SCI_SEPCHAR は int 0x05 であり、制御文字として使用

という感じです。

また、INT 0X80 による、IRQ を使用したシステムコールの実行をする関数にも同様の追記を行いました。

PID とその PPID を UDP 通信を用いて送信する

ここでは、PID の親子関係を特定するために PID とその PPID をすべて送信します。
具体的には、プロセスを生成する関数に追記を行い、生成された瞬間にその生成されたプロセスの PID とその親の PID を送信します。
ちなみにですが、プロセスを生成するシステムコールである fork、vfork、clone はすべて次の関数のラッパーとなっています。

(kernel/fork.c)

static int is_sci_client2_ready = 1;
struct sci_client_struct sci_cs2;
struct sci_client_struct *p_sci_cs2;
static int sci_pids[sci_wait_pid_num];
static int sci_ppids[sci_wait_pid_num];
static char sci_comms[sci_wait_pid_num][16];
static int sci_cnt = 0;

pid_t kernel_clone(struct kernel_clone_args *args)
{
	pid_t nr;

  (略)

	if ((int)nr > sci_wait_pid_num) {
		if (is_sci_client2_ready) {
			p_sci_cs2 = &sci_cs2;
			init_sci_client(p_sci_cs2, sci_port_ptree);
			is_sci_client2_ready = 0;

			int i;
			for (i = 0; i < sci_cnt; i++) {
				send_ptree_info(sci_pids[i], sci_ppids[i],
						sci_comms[i]);
			}
		}

		send_ptree_info((int)nr,
				(int)pid_nr(get_task_pid(current, PIDTYPE_PID)),
				p->comm);
	} else {
		sci_pids[sci_cnt] = (int)nr;
		sci_ppids[sci_cnt] =
			(int)pid_nr(get_task_pid(current, PIDTYPE_PID));
		snprintf(sci_comms[sci_cnt], 16, "%s", p->comm);
		sci_cnt = sci_cnt + 1;
	}
	return nr;
}

システムコールの情報を送信する関数とかなり違いがありますが、こっちの関数の if 文は「新しく作成される PID が 1001 以上になるまでは、ソケットの init および情報の送信をせず、代わりに配列に送信するデータを配列で保存しておく」ということをしています。
これは、「プロセスを作成する」という関数は OS の起動直後から使用されるが、UDP 通信は net 関連の init を行うプロセスが走ってから使用可能になる、つまり「プロセス生成関数が初めて呼び出される時にはまだ UDP 通信ができない」ことを対策しています。
システムコールはユーザ空間が動き始めてからなので、初めて「システムコールを実行する関数」が呼び出される時にはもう UDP は準備完了しているということです。
(よくこんなん気が付いたわ俺)

また、comm は最大 16 文字の文字列で、プロセスのもととなったプログラム名(例:bash など)が格納されてます。これは補助的な意味合いで取得しています。
PIDexample.png

ちなみに、取得データの一例はこんな感じで、gnome-software などは関係ないプロセス、PID=2073 の bash が解析対象を実行し始めたプロセス、そのあとの sudo と apt のプロセスは bash の子プロセスなので解析対象で...といった感じですね。

Makefile の記述方法

上記で(プログラミング的な)改造は終了です。
次に、カーネルソースをコンパイルしてやる必要があります。
そして、インターネットで検索してもあまりよくわからなかったこととして、Makefile の記述方法があります。

Makefile はコンパイルの方法や対象を記述するファイルで、ソースファイル(hogehoge.c)を追加したときにそのファイルを指定してやらなければなりません。
また、フォルダを追加し、その中にソースファイルを追加した場合は、そのフォルダの親フォルダの中の Makefile にフォルダ名を指定してやって、さらにそのフォルダの中に Makefile を作ってやらないといけません。

具体的には、次のようにします。

  • ファイルのみを追加した場合(net/sci_client.c を追加した場合):

    同じフォルダの Makefile である、net/Makefile に追加する。

変更前:

obj-$(CONFIG_NET)		:= devres.o socket.o core/

変更後:

obj-$(CONFIG_NET)		:= devres.o socket.o sci_client.o core/
  • フォルダを追加した場合

    研究初期はシステムコール関連のフォルダに UDP 通信関連のソースを入れていたので、その時の話になります。
    arch/x86/entry/ 配下に sci_client というフォルダを作成し、その中に sci_client.c と sci_client.h、sci_queue.c、sci_queue.h を追加した場合は次のようにします。

arch/x86/entry/Makefile に下記を追加

obj-y				+= sci_client/

arch/x86/entry/sci_client/Makefile に次のように記述

KASAN_SANITIZE			:= n
UBSAN_SANITIZE			:= n
KCSAN_SANITIZE			:= n
OBJECT_FILES_NON_STANDARD	:= y

obj-y		+=		sci_client.o
obj-y		+=		sci_queue.o

CFLAGS_client.o					+= -fno-stack-protector
CFLAGS_ClientSCIqueue.o			+= -fno-stack-protector

必要な情報のみに選別する

上記で OS の改造は終了です。
VirtualBox で上記の OS を起動すると、IP アドレス"192.168.56.1"(Virtual Box のホストオンリーアダプタ経由)のポート 15001 にシステムコール ID とその呼び出し元 PID が、ポート 15002 に PID とその PPID、その PID のプロセスのもととなったプログラム名が文字列として送信されます。

しかし、プログラムを見ての通り、システムコールは(PID に一切関係なく)呼び出されたものすべてを取得しているため、解析対象のプログラムのプロセスが呼び出したもののみに選別する必要があります。

PID_Tree.PNG

まず、PID とその PPID について、左のような情報が取得できたとすると、PID の親子関係を右のような木構造に展開できます。
syscall2Tree.PNG

次に、左のようなシステムコール ID とその呼び出し元 PID が取得できたとすると、右のように木構造に反映します。

あとは、例えば PID=2 が解析対象を実行し始めた PID であったとすると、2 を根とするサブツリー配下の PID の情報のみに選別します。

終わりに

最後まで読んでくださってありがとうございました。
説明を端折った部分が多かったですが、要望いただけましたら追加工事します(卒論のコピペ)。

今後については、この動的解析システムの性能評価がまだなので、修士 1 年の間に 1 本論文にしたいと考えています。
修士 2 年では、java と python 間での双方向翻訳に静的解析 + 動的解析 + ルールベースの 3 つを複合した方法で攻める論文を出そうと思っています。

以上です。
ありがとうございました。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2