セキュリティ・キャンプ全国大会2017 解析トラック D6

LSMから見た Linux カーネルのセキュリティ

2017.8.17

目次


第0章 概要


Linux カーネルの中でアクセス制御を行うための仕組みであるLSMと、無料で使えるウイルス対策ソフト ClamAV とを組み合わせて、オンアクセススキャンを実装することを目指します。その中で、LSMおよび Linux カーネルをめぐる話や、安全で丁寧なプログラムを書くためのコツを学びます。

なお、C言語を用いて自分が作成したいと思うプログラム(500行程度)を書くスキルと、事前学習期間中に実装まで済ませてしまうくらいの意気込みが必要です。

使用する環境について

Windows にインストールした Oracle VM VirtualBox 上で動作する CentOS 7 または Fedora 25 を使います。LSMモジュールをローダブルカーネルモジュールとして作成する以上、 Linux ではないOS上でユーザランドプログラムを動かしても意味がありませんので、 Linux 環境が必須です。
なお、カーネル空間で動作するプログラムの不具合は、システムにダメージを与える(例:ファイルシステムが損傷して全てのファイルを失ってしまう)可能性があります。そのため、破棄しても惜しくない Linux 環境を用意の上、実行するようにしてください。

RHEL 7.4 が2017/8/1にリリースされましたが、キャンプ本番までに CentOS 7.4 が間に合わない可能性があります。安定版のカーネルで試したいという方は、 CentOS 7.3 を使う準備をしていただければと思います。
Fedora 26 が2017/7/11にリリースされましたが、 ClamAV のコンパイルが失敗することが確認されたため、使いません。最新版のカーネルで試したいという方は、 Fedora 25 を使う準備をしていただければと思います。

Debian とか Gentoo とか ArchLinux とかを使いたい方は、それでも構いません。でも、熊猫が不慣れなために環境を用意できないため、自前で VirtualBox 用環境を用意していただくことになります。なお、 VineLinux はカーネルでLSMが有効になっていないため、対象外とさせてください。


第1章 ユーザ空間でC言語プログラミングについて体験する。


カーネルのソースコードは一部を除いてC言語で書かれているので、この講義では特に断りが無ければ「C言語で」という解釈をしてください。

1.1 ユーザ空間のプログラムで Hello world. を実現する。

Hello world. を表示するプログラムを書いて、コンパイルに必要なパッケージをインストールして、コンパイルを行って、実行してください。

表示する方法として、 printf() / fwrite() / write() の3パターンを使ってください。そして、それぞれのパターンについて、 strace コマンドおよび ltrace コマンド経由で実行することにより、どのような差異があるのかを観測/考察してください。

1.2 無限ループやデッドロック状態を体験する。

デッドロックを引き起こすプログラムを書いて、コンパイルを行って、実行することでデッドロック状態を引き起こしてください。そして、片方のプロセスを強制終了させることにより、デッドロック状態を解消するというのを体験してください。

カーネル内で動作するプログラムはマルチスレッドなのですが、ユーザ空間で動作するプログラムをマルチスレッドで書こうとすると様々な罠に引っかかるため、マルチプロセスで作成してください。

さて、マルチスレッドとマルチプロセスの違いは何でしょう?マルチプロセスなユーザランドプログラムを作るには、どんな関数(システムコール)を使えばいいのでしょう?デッドロックを発生させることができるシステムコールには何があるでしょう?プロセスを識別するには?プロセスを強制終了させるには?いろいろ調べることが出てきます。

一例として、 flock() システムコールを使ってデッドロックを発生させるプログラムを flock.c として貼っておきます。頑張ってみて、どうしても解らない場合には、ダウンロードしてください。自分が書いたプログラムで不具合を踏んでみるのは良い経験になりますよ。

1.3 NULL pointer dereference でクラッシュさせる。

NULL pointer dereference が発生するプログラムを書いて、コンパイルを行って、実行することで、何が起こるのかを確認してください。

1.4 0除算でクラッシュさせる。

0除算エラーが発生するプログラムを書いて、コンパイルを行って、実行することで、何が起こるのかを確認してください。

1.5 スタック変数を大量に使う。

スタックメモリ( malloc() などを使わずに割り当てるローカル変数など)を大量に使うプログラムを書いて、実行してください。そして、次第にスタックメモリの消費量を増やしていき、上限に達すると何が起こるのか、そして、その時の上限は何バイトくらいなのかを確認してください。(厳密な上限を知る必要はありません。)

うっかりミスを見つけるために、コンパイルする際には必ず -Wall オプションを付けて、出力された警告を極力解消するようにしてください。また、 -Wall オプションと一緒に最適化オプション(例: -O0 または -O1 )を指定することにより、出力される警告の内容や実行結果が変わることもあります。

1.6 連続したメモリを割り当てる。

ヒープメモリに( malloc() などを使って)連続したメモリを割り当てるプログラムを書いて、実行してください。そして、次第にヒープメモリの消費量を増やしていき、上限に達すると何が起こるのか、そして、その時の上限は何バイトくらいなのかを確認してください。(厳密な上限を知る必要はありません。)

(割り当てたメモリを解放しないことで)メモリリークを発生させることが目標ではなくて、(メモリリークしない状態で)どれくらいのサイズまで(連続したメモリアドレスに)割り当てできるかを確認することが目標です。

なお、割り当てただけではメモリをほとんど消費しません。割り当てたメモリ領域を読み書きすることでページフォールトを発生させ、実際にメモリが割り当てられるようにする必要があります。 Linux では int は32ビットですので、割り当てられたメモリの合計量をカウントする変数に unsigned int を使うと、4GB以上のメモリを搭載している環境では正しくカウントできません。割り当てられたメモリの合計量をカウントする変数では512GB程度までカウントできるようにしたいので、32ビットカーネルを使っている場合は long long を、64ビットカーネルを使っている場合は long または long long を使ってください。

1.7 メモリリークでクラッシュさせる。

メモリリークによりシステムのメモリを枯渇させるプログラムを書いて、実行してください。 Segmentation Fault による強制終了ではなく、 OOM killer による強制終了が発生すれば成功です。

システムのメモリを枯渇させるにはどのような方法でメモリを割り当てれば良いでしょうか?昨年の講義資料 http://I-love.SAKURA.ne.jp/The_OOM_CTF.html の中には、問題が発生するC言語のプログラム例がたくさん載っていますので、参考にしてください。

なお、 OOM killer が発生するような状況では、システムの動作が極端に遅くなります。 swap 領域を使用している方は、 swap 領域を無効化( swapoff -a )してから試してください。

1.8 第1章の振り返り

1.1 については、ユーザランドで動作するプログラムを作成してコンパイルおよび実行する手順、そして、 strace / ltrace で動作を追跡し、ライブラリ関数( printf fwrite )とシステムコール( write )との差異を確認していただけたかと思います。

1.2 については、マニュアルの調べ方を知り、問題が起きても SIGKILL で強制終了させることができるので影響範囲を限定できることを確認していただけたかと思います。

1.31.4 については、 NULL pointer dereference や0除算エラーが発生しても、プロセスが強制終了するだけで済むことを確認していただけたかと思います。

1.5 については、スタックメモリは8MBあるため、普通の使い方であれば十分であることを確認していただけたかと思います。

1.6 については、最近のPCでは8GBとか16GBとか搭載しているので、(32ビットカーネルでない限り)ほとんど全量を(仮想アドレス的に連続した状態で)割り当てできることを確認していただけたかと思います。

1.7 については、メモリリークがあった場合でも、 OOM killer によりプロセスが強制終了するだけで済むことを確認していただけたかと思います。

ユーザランドで動作するプロセスの場合、基本的にリソースはプロセスの終了により自動的に解放されるので、リソースのリークが原因でシステム全体がダウンすることはありません。


第2章 カーネル空間ではいろいろと違うことを体験する。


2.1 ローダブルカーネルモジュールで Hello world. を実現する。

Hello world. を表示するカーネルモジュールのソースコードを書いて、コンパイルに必要なパッケージをインストールして、コンパイルを行って、実行してください。

ユーザ空間での printf() 関数に対応する Linux カーネル内の関数は printk() です。カーネルモジュールを実行するというのは、 modprobe コマンドや insmod コマンドなどを用いてカーネルモジュール(拡張子が .ko のファイル)をロードすることに相当します。ユーザ空間で動作するC言語プログラムは実行すると main() 関数が呼ばれるのに対して、カーネル空間で動作するカーネルモジュールの場合はソースコード内の module_init() パラメータで指定されている関数が呼ばれます。(ちなみに、 module_init() パラメータで指定されている関数が 0 を返却した場合、カーネルモジュールはロードされたままの状態になり、 rmmod コマンドなどを用いてアンロードされるまでカーネル空間内に残存します。アンロードされる際には、クリーンアップ処理を呼び出すために、ソースコード内の module_exit() パラメータで指定されている関数が呼ばれます。)

カーネルモジュールをコンパイルする際には、 Makefile を使用します。使い方を知るには、カーネルソースツリーの Makefile を見てみるのが良いでしょう。例えば、 http://tomoyo.osdn.jp/cgi-bin/lxr/source/fs/Makefile を見ると

obj-$(CONFIG_ほにゃらら) += ほにゃらら.o

という行がありますよね?「 CONFIG_ほにゃらら」の部分はカーネルコンフィグファイル(カーネルソースツリー直下の .config 、インストールされた際には /boot/config-`uname -r` )で指定されている内容です。カーネルコンフィグファイルでは、ビルトインとしてコンパイルする場合は「 CONFIG_ほにゃらら=y 」という行が、ローダブルカーネルモジュールとしてコンパイルする場合は「 CONFIG_ほにゃらら=m 」という行が指定されており、 $(CONFIG_ほにゃらら) の部分が y または m に置換される訳です。

解けない方は http://akari.osdn.jp/1.0/chapter-3.html を見ながら 3.1 の始まりから 3.2 の終わりまで試してください。第4章で AKARI のソースコードを参考にするため、解けた方も試しておいて損はありません。

2.2 無限ループやデッドロック状態を体験する。

無限ループやデッドロック状態を引き起こすカーネルモジュールのソースコードを書いて、コンパイルを行って、実行することで、システムがどうなるのかを確認してください。

無限ループの書き方はいくつかありますが、何かのメッセージを表示しながらでも構いませんし、単純に while (1); だけでも構いません。

1.2 ではデッドロック状態を引き起こすプログラムを実行し、 KILL シグナルを送信することで強制終了できることを確認していただいたかと思います。無限ループ状態に陥っているカーネルモジュールについても、 KILL シグナルを送信することで強制終了できるかどうかを確認してください。

カーネル内で使えるロックの方法としては、 spinlock 、 semaphore 、 mutex などいろいろありますが、ここでは mutex を使います。 mutex のロックを獲得するには mutex_lock() という関数を、ロックを解放するには mutex_unlock() という関数を使います。 mutex を宣言する方法や初期化する方法については、カーネルソースツリーなどから探してください。

mutex を用いてデッドロック状態を発生させるには、 mutext_lock() を2回続けて呼び出すことで実現できます。デッドロック状態に陥っているカーネルモジュールについても、 KILL シグナルを送信することで強制終了できるかどうかを確認してください。

この課題は、2章の中では一番難しいと感じるかもしれません。でも、カーネル内で動作するプログラムを書く上での基本事項であり、避けて通れない道ですので、頑張ってください。コンパイルおよび実行の手順は 2.1 と同様です。

どうしても解らない場合には、 mutex.c をダウンロードして使ってください。解った人は、どう修正すれば SIGKILL シグナルを送信することでデッドロック状態を解消できるようになるのかを考えてみてください。(「安全で丁寧なプログラムを書くためのコツ」に関係しています。)

2.3 NULL pointer dereference でクラッシュさせる。

NULL pointer dereference が発生するカーネルモジュールを書いて、コンパイルを行って、実行することで、システムがどうなるのかを確認してください。なお、実行前に /proc/sys/kernel/panic_on_oops の値が 0 なのか 1 なのかも確認しておいてください。そして、 /proc/sys/kernel/panic_on_oops の値が、実行後のシステムの状態にどう影響するかを比較してください。

2.4 0除算でクラッシュさせる。

0除算エラーが発生するカーネルモジュールを書いて、コンパイルを行って、実行することで、システムがどうなるのかを確認してください。

2.5 スタック変数を大量に使う。

スタックメモリを大量に使うカーネルモジュールを書いて、コンパイルを行って、実行してください。そして、次第にスタックメモリの消費量を増やしていき、上限に達すると何が起こるのか、そして、その時の上限は何バイトくらいなのかを確認してください。(厳密な上限を知る必要はありません。)

2.6 連続したメモリを割り当てる。

メモリ割り当て関数を用いて連続したメモリを割り当てるカーネルモジュールを書いて、コンパイルを行って、実行してください。そして、次第に割り当てるサイズを増やしていき、上限に達すると何が起こるのか、そして、その時の上限は何バイトくらいなのかを確認してください。

なお、ユーザ空間での malloc() 関数に対応する Linux カーネル内の関数は kmalloc() 、ユーザ空間での free() 関数に対応する Linux カーネル内の関数は kfree() です。 malloc() と kmalloc() の違いとしては、 kmalloc() には割り当てたいサイズ(バイト数)の他に、「メモリを確保するために行うことが許される処理」を示す GFP フラグも指定する必要があるという点です。指定すべき GFP フラグは状況(例:スリープ可能なコンテキストか否か)により変化するのですが、現時点までに学習した範囲では GFP_KERNEL を指定できるので、 GFP_KERNEL を指定してください。

(割り当てたメモリを解放しないことで)メモリリークを発生させることが目標ではなくて、(メモリリークしない状態で)どれくらいのサイズまで(連続したメモリアドレスに)割り当てできるかを確認することが目標です。

2.7 メモリリークでクラッシュさせる。

「メモリリークによりシステムのメモリを枯渇させる」カーネルモジュールを書いて、コンパイルを行って、実行してください。やることは、無限ループの中で kmalloc() を呼び出して、メモリを割り当て続けるだけなのですが・・・驚くような結果が発生するものと思われます。

なお、カーネルが出力するメッセージを確認できるように、GUIデスクトップ環境ではなく、CUIコンソール環境から実行することを推奨します。また、実行する前に以下の設定をしておくことを推奨します。

echo 1 > /proc/sys/kernel/sysrq
echo 7 > /proc/sys/kernel/printk

2.8 第2章の振り返り

2.1 については、ローダブルカーネルモジュールを作成してコンパイルおよびロードする手順について確認していただけたかと思います。

2.2 については、(割り込み処理を除いて)他のプロセスに制御を移さなくなるため、 SIGKILL を送信できない状態に陥ります。仮に SIGKILL を送信することができたとしても強制終了させることができない状態です。なお、 mutex_lock() で SIGKILL を送信しても強制終了できない問題については、 mutex_lock_killable() を使うことで解決できます。処理を中断しても問題が発生しない場所では、処理を中断できるようにしておくことが大切です。そうしないと、強制終了ができなくなってしまいます。ただし、エラー処理を間違えやすいので注意しましょう。

2.32.4 については、 /proc/sys/kernel/panic_on_oops の設定にも依存しますが、 panic_on_oops が0以外に設定されている場合、カーネルパニックによりシステムがダウンしてしまいます。しかし、 panic_on_oops が0に設定されていても、 oops により強制終了させられたプロセスが保持していた資源は解放されないため、カーネルパニックによりシステムがダウンしなかったとしても、正常に稼働し続けられる保証はありません。ロックなどの状態に整合性が無いまま続けようとして不意に処理が停止してしまうリスクを冒すという選択肢と、素直にメモリダンプを取得してクリーンな状態から再スタートさせるという選択肢のどちらかを、管理者は選べるようになっています。

2.5 については、最近のカーネルにおけるスタックメモリは16KBのようです。昔は8KBでした。たくさんのスレッドを作れるようにするために4KBに設定( CONFIG_4KSTACKS )できた時期もありました。いずれにせよ、ユーザ空間で利用可能なスタックメモリ(デフォルトで8MB)と比べると、非常に少ないということを確認いただけたかと思います。スタックメモリの消費量が増えると、スタックオーバーフローが発生する可能性が増えるため、カーネルコンフィグで指定された閾値を超えると警告が出力されるようになっています。
  CONFIG_FRAME_WARN:
  Tell gcc to warn at build time for stack frames larger than this.
  Setting this too low will cause a lot of warnings.
  Setting it to 0 disables the warning.
  Requires gcc 4.4

2.6 については、連続したメモリを自由に割り当てられないことを確認いただけたかと思います。ちょうどD1~D3枠の事前学習用ページ http://rkx1209.hatenablog.com/entry/2017/07/13/184358 に kmalloc() の話も出ていますので、一度読んでいただければと思います。確保できるかどうかは、メモリ割り当て要求を行ったタイミングにおけるメモリの空き状況(断片化の発生状況)にも依存します。実際には、8ページ(1ページ=4096バイトなので、32KB)よりも大きいサイズについては、空きメモリが充分あったとしても断片化により割り当てが失敗する可能性があります。(その他にも、いろいろと条件があるのですが、省略します。)
ユーザ空間で割り当て可能なヒープメモリ(空きメモリさえあればGBレベルも可能)と比べると、非常に少ないということを確認いただけたかと思います。

2.7 については、カーネル空間でのメモリリークはシステムダウンに繋がる可能性があることを確認いただけたかと思います。(タイミング依存ですので、タイミングにより結果が異なるのですが)基本的に何らかの形でシステムがハングアップします。

空きメモリが枯渇すると、 OOM killer が発動することで空きメモリを確保しようとします。しかし、枯渇の原因がカーネル内でのメモリリークであった場合、ユーザ空間のプロセスをどれだけ強制終了させようとも、結局は枯渇してしまいます。そのため、最終的には全てのユーザ空間のプロセスを強制終了させた後、カーネルパニックになる・・・筈です。しかし、 OOM killer の発動には一定の条件を満たす必要があるため、 OOM killer が発動しないままシステムがハングアップしてしまうこともあります。

また、 Linux 4.8 以前のカーネルは OOM killer の処理が超楽観的であったため、たとえ OOM killer を発動できた場合でも、強制終了させようとしたプロセスがメモリを解放できない状況に陥り、メモリ枯渇状態を解決できないままシステムがハングアップしてしまうことがありました。それを扱ったのが、昨年の講義 http://I-love.SAKURA.ne.jp/The_OOM_CTF.html です。

カーネルの世界は、ユーザランドの世界では当たり前のようにできていたことができない窮屈な世界です。カーネルは究極のマルチスレッドプログラムであり、システムをシャットダウンまたは再起動するまで動き続けるため、リソース管理も大変です。いろいろな制約があるので、ユーザ空間でできることはユーザ空間でやりましょう。ユーザ空間からの攻撃とカーネル空間内のバグに注意しながら。

おまけ:カーネル内でのメモリリークによるDoS脆弱性の一例

今年のエイプリルフールに http://kernsec.org/pipermail/linux-security-module-archive/2017-April/000331.html に報告された Memory Leak Local DoS 不具合について紹介します。

C言語で書くと以下のようになります。

#include <sys/types.h>
#include <keyutils.h> /* Needs "-lkeyutils" option. */

int main(int argc, char *argv[])
{
        while (1)
                keyctl_set_reqkey_keyring(KEY_REQKEY_DEFL_THREAD_KEYRING);
        return 0;
}

アセンブラで書くと以下のようになります。

; nasm -f elf key.asm && ld -s -m elf_i386 -o key key.o
section .text
    CPU 386
    global _start
_start:
; while (1) keyctl_set_reqkey_keyring(KEY_REQKEY_DEFL_THREAD_KEYRING);
    mov eax, 288 ; NR_keyctl
    mov ebx, 14  ; KEYCTL_SET_REQKEY_KEYRING
    mov ecx, 1   ; KEY_REQKEY_DEFL_THREAD_KEYRING
    int 0x80
    jmp _start

嘘みたいに簡単なプログラムです。こんなに小さなコードなら、シェルコードを実行できるようなバッファオーバーフローがあれば、この Memory Leak Local DoS コードも実行できてしまいそうですよね。

この不具合は Copy On Write Credentials が導入された Linux 2.6.29 (2009年3月)から存在していたものであり、8年間の間、誰にも気づかれませんでした。この不具合により何が起こるかを実験したい方は、例えば ubuntu-17.04-desktop-amd64.iso のように2017年4月初めまでにコンパイルされたカーネルで起動していただければと思います。

権限昇格の脆弱性は「自分が管理しているシステムも狙われるかもしれない」という感じで騒ぎになりますが、 Local DoS の脆弱性は「誰が攻撃するの?」という感じで全く注目されません。熊猫はメモリ管理サブシステムにおける Local DoS に繋がる不具合を人力ファジングにより地道にテストしながら修正を提案している訳ですが、現実に起こる問題だとは考えられておらず、なかなか修正が採用されないでいます。(苦笑)

この講義に関して言えば、「カーネル内でのメモリリークは致命傷に繋がる可能性があるので、メモリの割り当て/解放処理が正しく行われているかどうか注意しましょう」ということでしょう。


第3章  ClamAV を試す。


第3章では ClamAV に触れてもらいます。

3.1 ClamAV をソースコードからコンパイルする。

以下に CentOS 7 での手順例を示します。

CentOS 7 を CentOS-7-x86_64-Minimal-1611.iso を用いてインストールします。
インストールして再起動した後、 root ユーザで以下の操作を行います。

(1) 最新のパッケージにアップデートします。

yum -y update

(2) ClamAV は EPEL レポジトリにあるので、 EPEL レポジトリを追加します。

yum -y install epel-release

(3) ClamAV の動作を試すために、バイナリのパッケージをインストールします。

yum -y install clamav clamav-update

(4) ソースから再コンパイルするために、 yum-utils パッケージと rpm-build パッケージをインストールします。

yum -y install yum-utils rpm-build

(5) ClamAV をソースのパッケージからビルドします。(バージョン部分はディストリビューションにより
    異なっている可能性があります。また、 CentOS 7 でもアップデートにより変化している可能性があります。)

yumdownloader --source clamav
rpm --checksig clamav-0.99.2-8.el7.src.rpm
rpm -ivh clamav-0.99.2-8.el7.src.rpm
yum-builddep -y ~/rpmbuild/SPECS/clamav.spec
rpmbuild -bb ~/rpmbuild/SPECS/clamav.spec

(6) ビルドした際に生成された検体を用いてテストを行います。

freshclam
clamscan ~/rpmbuild/BUILD/clamav-0.99.2/test/*

上記は CentOS 7 の場合で説明しました。 Fedora 25 の場合は以下の手順です。他の環境を使いたい方は、以下の操作と同等の手順を行ってください。

dnf -y update
reboot
dnf -y install clamav clamav-update
dnf -y install rpm-build
dnf download --source clamav
rpm --checksig clamav-0.99.2-1.fc25.src.rpm
rpm -ivh clamav-0.99.2-1.fc25.src.rpm
dnf -y builddep ~/rpmbuild/SPECS/clamav.spec
rpmbuild -bb ~/rpmbuild/SPECS/clamav.spec
sed -i -e 's/^Example/#Example/' /etc/freshclam.conf
freshclam
clamscan ~/rpmbuild/BUILD/clamav-0.99.2/test/*

3.2 libclam のAPIを使ってスキャンを行うプログラムを作成する。

この講義ではLSMフックからの通知を受けて、スキャンを行って、LSMフックに返事を返すようなユーザ空間プログラムを使用しますが、 clamscan のソースをベースにするのは負担が大きいと考えます。そのため、ソースパッケージに含まれている API の使い方サンプルである ex1.c をベースにします。
まずは、 ex1.c をコンパイルしたものを用いてテストを行います。( Fedora の場合は yum コマンドの部分が dnf コマンドに置き換わる以外は同様です。)

yum -y install clamav-devel
cd ~/rpmbuild/BUILD/clamav-0.99.2/examples/
gcc -Wall ex1.c -o ex1 -lclamav
for i in ~/rpmbuild/BUILD/clamav-0.99.2/test/*; do echo $i; ./ex1 $i; done

次に、 ex1.c が何をしているのか(どのように ClamAV の API を呼び出せばよいのか)を理解してください。 ex1.c から参照される他のファイル(つまり API の実装部分)の処理内容までは理解しなくても構いません。

less ex1.c

ex1.c はコマンドライン引数として1個のファイルしか指定できないため、毎回初期化処理を呼ぶことになってしまい、効率が悪いです。そのため、 ex1.c をベースにしながら、コマンドラインで指定された全てのファイルを1回の実行でできるように修正した ex2.c を作成します。そして、作成した ex2.c をコンパイルしたものを用いて再度テストを行います。(以下ではエディタとして emacs を使っていますが、好きなものを使ってください。)

yum -y install emacs-nox
emacs ex2.c
gcc -Wall ex2.c -o ex2 -lclamav
./ex2 ~/rpmbuild/BUILD/clamav-0.99.2/test/*

ex2.c は ex1.c をベースに自力で書けるようになって欲しいのですが、どうしても解らない場合には、改造結果例 ex2.c を見て、違いを考えてみてください。

最終的には ex2.c をコマンドラインで指定された数だけ繰り返す(有限ループ)のではなくイベントドリブンでいつまでも繰り返す(無限ループ)ように変形して使うことになります。どのように修正するかは、LSMモジュールとのインタフェース(通信プロトコル)を決めないと決められないため、現時点ではまだ修正しません。


第4章 LSMから ClamAV を呼び出す。


第4章ではLSMを用いてファイルのオープン要求をフックし、対象ファイルのパス名を計算し、そのパス名を securityfs 経由でユーザ空間に渡し、 ClamAV のAPIを呼び出すことでスキャンを行い、スキャンの結果ウィルスが検出された場合にはファイルのオープンを拒否するという形でオンアクセススキャンを行う実装に挑戦してもらいます。キャンプ本番での枠は4時間しかないので、事前学習期間中にどこまで進められるかが勝負になります。

4.1 環境を用意する。

カーネルモジュールについては、LSMインタフェースをローダブルカーネルモジュールから利用できるように設計されている AKARI または CaitSith をベースにします。

カーネルモジュールを開発するための環境に対して、以下の操作を行うことでソースコードをダウンロードしてください。( Fedora の場合は yum コマンドの部分が dnf コマンドに置き換わる以外は同様です。)

yum -y install kernel-devel
yum -y install subversion
mkdir ~/SVN
cd ~/SVN/
svn checkout https://svn.osdn.net/svnroot/tomoyo/
svn checkout https://svn.osdn.net/svnroot/akari/
svn checkout https://svn.osdn.net/svnroot/caitsith/

この操作により、 ~/SVN/tomoyo/ ~/SVN/akari/ ~/SVN/caitsith/ というディレクトリが作成されます。 AKARI をベースにする場合は ~/SVN/akari/trunk/akari/ 配下を、 CaitSith をベースにする場合は ~/SVN/caitsith/trunk/caitsith-patch/caitsith/ 配下を利用します。どちらをベースにしても構いません。以後、特に断りが無ければ akari と caitsith とを区別せずに akari と表記します。

akari/ というディレクトリの中の test.c というのが、第4章のテンプレートとして使用するファイルになります。

本来はローダブルカーネルモジュールからはLSMにアクセスすることができないのですが、 probe.c がLSMにアクセスするのに必要な処理を引き受けてくれているため、毎回カーネルをソースコードからリビルドすることなく(コンパイルの時間を節約しながら)LSMを使うことができるようになっています。でも、アンロード処理の面倒は見ないため、再ロードするためにシステムを再起動させる処理は必要になります。

4.2 ファイルのオープン処理をフックするLSMモジュールを作成する。

まずは、 akari/test.c を参照してください。この中にはLSMインタフェースをローダブルカーネルモジュールから利用できるようにするための手順が書かれていますので、このソースコードをコンパイルしてロードしてください。

cd /usr/src/kernels/`uname -r`/
cp -a ~/SVN/akari/trunk/akari/ .
make SUBDIRS=$PWD/akari
insmod akari/akari_test.ko

もし、 insmod がエラーとなる場合、ご利用の環境には対応できませんので、他の環境を用意してください。 insmod がエラーとならなかった場合は、ご利用の環境に対応できますので、アンロードしてください。

rmmod akari_test

AKARI は Linux 2.6.0 以降、 CaitSith は Linux 2.6.27 以降で動作できるように作られていますが、LSMのインタフェース(利用可能なフック関数や、フック関数に渡される引数の数/型)はカーネルバージョンにより異なります。そのため、カーネルバージョンにより記述内容を修正する必要があるということを意識しておいてください。

次に、 akari/lsm.c を参照してください。この中には、カーネルのバージョン毎に異なる akari/lsm-*.c を使うための指定が行われています。例えば CentOS 7 の場合は Linux 3.10 ですので akari/lsm-2.6.29.c を、 Fedora 25 の場合は Linux 4.11 ですので akari/lsm-4.7.c を参照してください。

lsm-2.6.29.c あるいは lsm-4.7.c の中には、LSMインタフェースから呼び出してもらうためのたくさんのフック関数と、それらのフック関数を登録するための処理が記述されています。ファイルは大きいですが、この講義ではこの中のごく一部だけを利用します。処理の流れを説明する都合上、 Linux 3.10 の場合に利用される lsm-2.6.29.c について先に説明します。

まず、 module_init() パラメータで指定されている ccs_init() という関数を参照してください。複数のカーネルバージョンで動作できるようにするための条件分岐がありますが、この講義で必要なのは以下の部分だけです。

---------- lsm-2.6.29.c ----------
static int __init ccs_init(void)
{
        struct security_operations *ops = probe_security_ops();
        if (!ops)
                goto out;
        ccsecurity_exports.d_absolute_path = probe_d_absolute_path();
        if (!ccsecurity_exports.d_absolute_path)
                goto out;
        ccs_update_security_ops(ops);
        return 0;
out:
        return -EINVAL;
}
---------- lsm-2.6.29.c ----------

次に ccs_update_security_ops() という関数を参照してください。たくさんの行がありますが、この講義で必要とするのは以下の部分だけです。

---------- lsm-2.6.29.c ----------
static void __init ccs_update_security_ops(struct security_operations *ops)
{
        swap_security_ops(file_open);
}
---------- lsm-2.6.29.c ----------

swap_security_ops() はマクロとして以下のように定義されています。

---------- lsm-2.6.29.c ----------
#define swap_security_ops(op)                                           \
        original_security_ops.op = ops->op; smp_wmb(); ops->op = ccs_##op;
---------- lsm-2.6.29.c ----------

つまり、 swap_security_ops(file_open); という行は

original_security_ops.file_open = ops->op; smp_wmb(); ops->op = ccs_file_open;

のように展開されます。

original_security_ops は以下のように定義されています。

---------- lsm-2.6.29.c ----------
static struct security_operations original_security_ops /* = *security_ops; */;
---------- lsm-2.6.29.c ----------

struct security_operations は include/linux/security.h で定義されている構造体で、これがLSMインタフェースと呼ばれているものです。カーネルの中からこの構造体に指定されているフック関数を呼び出し、フック関数の中で(パーミッションのチェックなどを行うことにより)アクセスの可否を判断します。

次に、 ccs_file_open() という関数を参照してください。

---------- lsm-2.6.29.c ----------
static int ccs_file_open(struct file *f, const struct cred *cred)
{
        int rc = ccs_open(f);
        if (rc)
                return rc;
        while (!original_security_ops.file_open)
                smp_rmb();
        return original_security_ops.file_open(f, cred);
}
---------- lsm-2.6.29.c ----------

この関数では、 ccs_open() という関数を呼び出し、 ccs_open() が 0 を返却した場合には original_security_ops.file_open() という関数も呼び出しています。

次に、 ccs_open() という関数を参照してください。

---------- lsm-2.6.29.c ----------
static int ccs_open(struct file *f)
{
        return ccs_open_permission(f);
}
---------- lsm-2.6.29.c ----------

この講義では、 ccs_open() 関数に相当する部分から ClamAV によるオンアクセススキャンを行い、ウィルスが検出された場合にはアクセスを拒否(負の値を返却)し、それ以外の場合にはアクセスを許可( 0 を返却)するという処理を実装することを目指します。

次に、 Linux 4.11 の場合に利用される lsm-4.7.c について説明します。 module_init() パラメータで指定されている ccs_init() という関数を参照してください。この講義で必要なのは以下の部分だけです。

---------- lsm-4.7.c ----------
static int __init ccs_init(void)
{
        int idx;
        struct security_hook_heads *hooks = probe_security_hook_heads();
        if (!hooks)
                goto out;
        for (idx = 0; idx < ARRAY_SIZE(akari_hooks); idx++)
                akari_hooks[idx].head = ((void *) hooks)
                        + ((unsigned long) akari_hooks[idx].head)
                        - ((unsigned long) &probe_dummy_security_hook_heads);
        ccsecurity_exports.d_absolute_path = probe_d_absolute_path();
        if (!ccsecurity_exports.d_absolute_path)
                goto out;
        for (idx = 3; idx < ARRAY_SIZE(akari_hooks); idx++)
                add_hook(&akari_hooks[idx]);
        return 0;
out:
        return -EINVAL;
}
---------- lsm-4.7.c ----------

akari_hooks は security_hook_list 型の配列です。 security_hook_list は include/linux/lsm_hooks.h で定義されている構造体で、フック関数のリストを保持するために使われます。この講義では以下の1行だけを利用します。

---------- lsm-4.7.c ----------
static struct security_hook_list akari_hooks[] = {
        MY_HOOK_INIT(file_open, ccs_file_open),
}
---------- lsm-4.7.c ----------

MY_HOOK_INIT() はマクロとして以下のように定義されています。

---------- lsm-4.7.c ----------
#define MY_HOOK_INIT(HEAD, HOOK)                                \
        { .head = &probe_dummy_security_hook_heads.HEAD,        \
                        .hook = { .HEAD = HOOK } }
---------- lsm-4.7.c ----------

つまり、 MY_HOOK_INIT(file_open, ccs_file_open) という行は

akari_hooks[0] = {
        .head = &probe_dummy_security_hook_heads.file_open,
        .hook = { .file_open = ccs_file_open },
}

のように展開されます。

add_hook() という関数は以下のように定義されており、LSMインタフェースが使用しているリストに、このモジュールで使用するフック関数のエントリを追加するという処理を行っています。

---------- lsm-4.7.c ----------
static inline void add_hook(struct security_hook_list *hook)
{
        list_add_tail_rcu(&hook->list, hook->head);
}
---------- lsm-4.7.c ----------

複数のフック関数を呼び出す部分をLSMインタフェース側が提供しているため、 ccs_file_open() 関数の中から original_security_ops.file_open() という関数を呼び出す必要は無くなっています。

---------- lsm-4.7.c ----------
static int ccs_file_open(struct file *f, const struct cred *cred)
{
        return ccs_open_permission(f);
}
---------- lsm-4.7.c ----------

なお、この講義で使用する akari_hooks 配列の長さは1なので、それに合わせて ccs_init() 関数の処理を以下のように修正して使います。(配列の長さが1なので、配列にしなくても構いません。)

---------- lsm-4.7.c ----------
static int __init ccs_init(void)
{
        struct security_hook_heads *hooks = probe_security_hook_heads();
        if (!hooks)
                goto out;
        akari_hooks[0].head = ((void *) hooks)
                        + ((unsigned long) akari_hooks[0].head)
                        - ((unsigned long) &probe_dummy_security_hook_heads);
        ccsecurity_exports.d_absolute_path = probe_d_absolute_path();
        if (!ccsecurity_exports.d_absolute_path)
                goto out;
        add_hook(&akari_hooks[0]);
        return 0;
out:
        return -EINVAL;
}
---------- lsm-4.7.c ----------

さて、 ccs_open() 関数に相当する部分を実装することを目指す訳ですが、その前に、ここまでの内容を実際に動作確認してみましょう。フック関数が登録されて実際に呼ばれていることを確認できるようにするために、とりあえず以下のように printk() でファイル名を出力するだけのフック関数に書き直して、コンパイルおよびロードしてください。

---------- lsm-2.6.29.c ----------
static int ccs_open(struct file *f)
{
        rcu_read_lock();
        printk(KERN_INFO "Opening '%s'\n", f->f_path.dentry->d_name.name);
        rcu_read_unlock();
        return 0;
}
---------- lsm-2.6.29.c ----------

---------- lsm-4.7.c ----------
static int ccs_file_open(struct file *f, const struct cred *cred)
{
        rcu_read_lock();
        printk(KERN_INFO "Opening '%s'\n", f->f_path.dentry->d_name);
        rcu_read_unlock();
        return 0;
}
---------- lsm-4.7.c ----------

無事にコンパイルおよびロードできた場合、ファイルのオープン要求が発生する度に、(ディレクトリ部分を除いた)ファイル名部分が表示されるようになる筈です。きっと解らない部分があると思いますので、参考用に akari/test.c の改造結果例を添付しておきます。( test-3.10-1.c が Linux 3.10 用、 test-4.11-1.c が Linux 4.11 用です。)

なお、フック関数の処理中に(そのフック関数が使用するコード/データを含むメモリ領域が)アンロードされるとクラッシュしてしまいます。アンロード時にクラッシュする可能性を排除するために、アンロード用の処理は実装されていません。よって、同じモジュールを修正して再ロードする際には、システムを再起動することでアンロードするようにしてください。

4.3 オープン対象のファイルを絶対パス名で表示するLSMモジュールを作成する。

オンアクセススキャンを行うためには、スキャン対象のファイルを識別できるようにする必要があります。 4.2 では、ファイルのオープン要求が発生した際に(ディレクトリ部分を除いた)ファイル名部分が表示されるようにしましたが、ディレクトリ部分が不明なままではファイルを識別できません。

そこで、今回は、ディレクトリ部分を含んだパス名を算出するという処理を実装します。これにより、ファイルのオープンが要求された際に、絶対パス名が判明するため、スキャン対象のファイルを識別できるようになる筈です。(実際には、名前空間という機能が存在することにより、識別できない可能性があるのですが、この講義では名前空間については扱いません。)

akari/realpath.c というファイルを参照してください。この中には、指定されたパス( vfsmount 構造体のポインタと dentry 構造体のポインタをメンバとして含む path 構造体)を受け取って、パス名( char * )を計算するための処理が書かれています。この講義で不要な処理を削っていくと、 Linux 3.10 および Linux 4.11 で使用するのは以下の部分だけになります。

---------- realpath.c ----------
/**
 * ccs_realpath - Returns realpath(3) of the given pathname but ignores chroot'ed root.
 *
 * @path: Pointer to "struct path".
 *
 * Returns the realpath of the given @path on success, NULL otherwise.
 *
 * This function uses kmalloc(), so caller must kfree() if this function
 * didn't return NULL.
 */
char *ccs_realpath(const struct path *path)
{
        char *buf = kmalloc(PAGE_SIZE, GFP_KERNEL);
        char *name = NULL;
        char *pos;

        if (!buf)
                return NULL;
        pos = ccsecurity_exports.d_absolute_path(path, buf, PAGE_SIZE);
        if (!IS_ERR(pos))
                name = kstrdup(pos, GFP_KERNEL);
        kfree(buf);
        return name;
}
---------- realpath.c ----------

この関数に渡す path 構造体は、 ccs_open() 関数に渡されている file 構造体の f->f_path メンバーです。そのため、絶対パス名を表示するには ccs_open() 関数を以下のように書き換えることで実現できます。

---------- test.c ----------
static int ccs_open(struct file *f)
{
        char *pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                printk(KERN_INFO "Opening '%s'\n", pathname);
                kfree(pathname);
        }
        return 0;
}
---------- test.c ----------

さて、ここでクイズです。パス名の最大長は何バイトでしょうか?

マニュアルによると、パス名を渡すシステムコール( open() など)では PATH_MAX バイトが上限とあり、 PATH_MAX は 4096 バイト(4KB)と定義されています。ということは、パス名の最大長は(文字列の終端を示すための '\0' を含めて) 4096 バイトということになるのでしょうか?

実は「パス名の最大長に制限は無い」というのが正解です。 PATH_MAX バイトというのはパス名を文字列としてシステムコールに渡す際に渡すことができる上限であって、相対パス名を使えば無限に長いパス名を作成/参照することが可能になります。

そのため、 PATH_MAX バイトよりも長いパス名を計算できるようにしたい場合には以下のように割り当てるサイズを動的に変更していく必要があります。

---------- realpath.c ----------
char *ccs_realpath(const struct path *path)
{
        char *buf = NULL;
        char *name = NULL;
        unsigned int buf_len = PAGE_SIZE / 2;

        while (1) {
                char *pos;

                buf_len <<= 1;
                kfree(buf);
                buf = kmalloc(buf_len, GFP_KERNEL);
                if (!buf || !buf_len)
                        break;
                pos = ccsecurity_exports.d_absolute_path(path, buf, buf_len);
                if (IS_ERR(pos))
                        continue;
                name = kstrdup(pos, GFP_KERNEL);
                break;
        }
        kfree(buf);
        return name;
}
---------- realpath.c ----------

2.6 で、 kmalloc() を使って割り当て可能なメモリの上限がどれくらいかを確認してもらいました。ユーザ空間では malloc() を用いてGBレベルのサイズでも(空きメモリさえあれば)割り当てることができましたが、カーネル空間では原則として32KB以下しか割り当てできませんでした。(32KBより大きい場合、割り当てに失敗する可能性が非常に高くなります。)そのため、上記のように割り当てるサイズを動的に変更したとしても、無限に長いパス名を計算することはできないという制約事項があります。なお、「無限に長いパス名を作成/参照しようとする悪意あるユーザによるアクセスを許した時点で負け」という考えのようで、この制約事項が解消される予定はありません。

この講義では、オンアクセススキャンを行う対象となるファイルを open() システムコールに渡すことで読み込むため、結局は PATH_MAX バイトの制約を受けることになってしまいます。よって、上記のように割り当てるサイズを動的に変更する必要はありません。

ということで、ここまでの内容を実際に動作確認してみましょう。無事にコンパイルおよびロードできた場合、ファイルのオープン要求が発生する度に、ディレクトリ部分を含むファイル名が表示されるようになる筈です。参考用に akari/test.c の改造結果例を添付しておきます。( test-3.10-2.c が Linux 3.10 用、 test-4.11-2.c が Linux 4.11 用です。)

4.4 ユーザ空間との通信を行うLSMモジュールを作成する。

第2章で体験していただいたように、カーネル内で行われる処理にはいろいろな制約があります。そのため、ユーザ空間で行える処理はユーザ空間で行うべきです。ユーザ空間でオンアクセススキャンを行うためには、スキャン対象のファイルの情報をカーネル空間からユーザ空間に転送し、スキャンの結果をユーザ空間からカーネル空間へと通知する必要があります。

4.3 では、ファイルのオープン要求が発生した際に、ディレクトリ部分を含むファイル名を表示できるようにしました。今回は、ディレクトリ部分を含むファイル名をユーザ空間に渡して、ユーザ空間がファイル名を受け取ったことをカーネル空間に通知するという処理を実装するための準備をします。

まずは、以下のコードを見てください。

/* Operations for /sys/kernel/security/scan_file interface. */
static const struct file_operations sf_operations = {
        .open    = scanfile_open,
        .release = scanfile_release,
        .poll    = scanfile_poll,
        .read    = scanfile_read,
        .write   = scanfile_write,
};

/**
 * sf_securityfs_init - Initialize /sys/kernel/security/scan_file interface.
 *
 * Returns nothing.
 */
static void __init sf_securityfs_init(void)
{
        securityfs_create_file("scan_file", S_IFREG | 0600, NULL, NULL,
                               &sf_operations);
}

securityfs_create_file() というのは、 /sys/kernel/security/ にマウントされる securityfs というファイルシステム上にファイルを作成する関数です。ここでは、ファイルシステムの / ディレクトリ直下に scan_file という名前のファイルをパーミッション 0600 で作成しています。このファイルに対して適用される操作を指定しているのが file_operations 構造体の変数(ただし const が指定されているので変化しない)である sf_operations です。

open というメンバに指定されている scanfile_open() というのが、ファイルをオープンするときに呼ばれる関数です。この関数が適用される /sys/kernel/security/scan_file は、カーネル空間からユーザ空間への転送( read 操作)とユーザ空間からカーネル空間への通知( write 操作)を行うためのファイルであるため、読み書きモードでオープンしないと意味がありません。したがって、とりあえず、以下のように、読み書きモードではない場合にはオープンさせないという処理だけを行う関数を記述します。(後で修正します。)

/**
 * scanfile_open - open() for /sys/kernel/security/scan_file interface.
 *
 * @inode: Pointer to "struct inode".
 * @file:  Pointer to "struct file".
 *
 * Returns 0 on success, negative value otherwise.
 */
static int scanfile_open(struct inode *inode, struct file *file)
{
        if ((file->f_mode & (FMODE_WRITE | FMODE_READ)) != (FMODE_WRITE | FMODE_READ))
                return -EINVAL;
        return 0;
}

release というメンバに指定されている scanfile_release() というのが、ファイルをクローズするときに呼ばれる関数です。とりあえず、以下のように何もしない関数を記述します。(後で修正します。)

/**
 * scanfile_release - close() for /sys/kernel/security/scan_file interface.
 *
 * @inode: Pointer to "struct inode".
 * @file:  Pointer to "struct file".
 *
 * Returns 0.
 */
static int scanfile_release(struct inode *inode, struct file *file)
{
        return 0;
}

read というメンバに指定されている scanfile_read() というのが、ファイルを読み込む(カーネル空間からユーザ空間への転送を行う)ときに呼ばれる関数です。同様に write というメンバに指定されている scanfile_write() というのが、ファイルに書き込む(ユーザ空間からカーネル空間への通知を行う)ときに呼ばれる関数です。 poll というメンバに指定されている scanfile_poll() というのが、ファイルの読み書きができるようになるのを待つときに呼ばれる関数です。

でも、これらの関数の処理内容を説明する前に、プロトコル(やり取りする際の約束事/手順)を決める必要があります。よって、とりあえず、以下のように何もしない関数を記述します。(後で修正します。)

/**
 * scanfile_write - write() for /sys/kernel/security/scan_file interface.
 *
 * @file:  Pointer to "struct file".
 * @buf:   Pointer to buffer.
 * @count: Size of @buf.
 * @ppos:  Unused.
 *
 * Returns @count on success, negative value otherwise.
 */
static ssize_t scanfile_write(struct file *file, const char __user *buf,
                              size_t count, loff_t *ppos)
{
        return count;
}

/**
 * scanfile_poll - poll() for /sys/kernel/security/scan_file interface.
 *
 * @file: Pointer to "struct file".
 * @wait: Pointer to "poll_table". Maybe NULL.
 *
 * Returns POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM if ready to read/write,
 * POLLOUT | POLLWRNORM otherwise.
 */
static unsigned int scanfile_poll(struct file *file, poll_table *wait)
{
        return POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM;
}

/**
 * scanfile_read - read() for /sys/kernel/security/scan_file interface.
 *
 * @file:  Pointer to "struct file".
 * @buf:   Pointer to buffer.
 * @count: Size of @buf.
 * @ppos:  Unused.
 *
 * Returns bytes read on success, negative value otherwise.
 */
static ssize_t scanfile_read(struct file *file, char __user *buf, size_t count,
                             loff_t *ppos)
{
        return 0;
}

まず、基本的な考え方として、システム上には多数のプロセスが稼働しているため、ファイルのオープン要求も同時に複数発生する可能性があります。そのため、複数のオープン要求が発生した場合でも、正しく扱えなければいけません。

いくつの要求が同時に発生するかを予測できない場合、固定サイズの配列だと上限に達した際に破綻してしまうため、以下のように scanfile_waiting_list というリストを用いて扱うことにします。

/* Structure for file scan request. */
struct scanfile_entry {
        struct list_head list;
        const char *pathname;
        size_t pathname_len;
        unsigned int serial;
        u8 timer;
        u8 answer;
};

/* The list for "struct scanfile_entry". */
static LIST_HEAD(scanfile_waiting_list);

/* Lock for manipulating scanfile_waiting_list. */
static DEFINE_SPINLOCK(scanfile_list_lock);

scanfile_entry 構造体は、 scanfile_waiting_list というリストが扱う対象となるデータです。 scanfile_waiting_list というリストに連結するために使う list_head 構造体の他に、オンアクセススキャン対象となるパス名、そのパス名の長さ、シリアル番号、タイムアウトを検知するためのカウンタ、スキャン結果を保持するための変数があります。

シリアル番号 serial メンバは必須ではありませんが、あったほうが便利なので使っています。というのも、 scanfile_waiting_list というリストに連結された scanfile_entry 構造体を探すときにパス名である pathname メンバで比較するのは面倒ですし、 read() 時と write() 時に同じパス名を渡すのも冗長です。パス名は長くなる可能性がありますが、シリアル番号なら短いので、 write() 時にスタックメモリ上にバッファを確保できるというメリットもあります。

タイムアウトを検知するためのカウンタ timer メンバは、ユーザ空間で何かの問題(例:デッドロック状態)が発生した場合に備えて、永遠に待ち続けないようにするために使います。

スキャン結果 answer メンバの値は、 0 の場合は結果待ち、 2 の場合は明示的な拒否(ウィルスが検知された)、 1 の場合は明示的な許可(ウィルスが検知された以外の全て)と定義します。

つまり、ユーザ空間でスキャンを行うプログラムは、 /sys/kernel/security/scan_file というファイルからシリアル番号とパス名の情報を一緒に受け取り、スキャンを行って、シリアル番号とスキャンの結果( 1 または 2 )を一緒に返却すればよいということになります。

ということで、スキャン対象のファイルの情報をカーネル空間からユーザ空間に転送し、結果をユーザ空間からカーネル空間へと通知するために必要な処理の内、カーネル側が実行する部分である call_scanfile() という関数を考えてみます。

/* Wait queue for kernel -> userspace notification. */
static DECLARE_WAIT_QUEUE_HEAD(scanfile_entry_wait);
/* Wait queue for userspace -> kernel notification. */
static DECLARE_WAIT_QUEUE_HEAD(scanfile_response_wait);

/**
 * call_scanfile - Do file scan in user space.
 *
 * @pathname: Pathname to scan.
 *
 * Returns 0 if the userspace decided to permit the access request
 * or scan timed out, -EPERM otherwise.
 */
static int call_scanfile(const char *pathname)
{
        int error = 0;
        static unsigned int sf_serial;
        struct scanfile_entry entry = { };

        entry.pathname = pathname;
        entry.pathname_len = strlen(entry.pathname) + 1;
        spin_lock(&scanfile_list_lock);
        entry.serial = sf_serial++;
        list_add_tail(&entry.list, &scanfile_waiting_list);
        spin_unlock(&scanfile_list_lock);
        /* Give 60 seconds for user space operation. */
        while (entry.timer < 60) {
                wake_up_all(&scanfile_entry_wait);
                if (wait_event_timeout
                    (scanfile_response_wait, entry.answer, HZ))
                        break;
                else
                        entry.timer++;
        }
        spin_lock(&scanfile_list_lock);
        list_del(&entry.list);
        spin_unlock(&scanfile_list_lock);
        switch (entry.answer) {
        case 2: /* Rejected. */
                error = -EPERM;
                break;
        case 1:
                /* Granted. */
                error = 0;
                break;
        default:
                /* Timed out. */
                error = 0;
                break;
        }
        return error;
}

さて、 call_scanfile() の処理内容が決まったことで、ユーザ空間側が呼び出す部分である scanfile_read() と scanfile_write() の処理内容を書けるようになったのでしょうか?いいえ、まだいくつか検討すべき事柄があります。

まず、ユーザ空間でスキャンを行うプログラムが動作していなかった場合、どう対処したらよいのでしょう?タイムアウトを検知するためのカウンタを使えば永遠に待ち続けるという事態は避けることができます。(実際、上記のコードであれば60秒でタイムアウトします。)しかし、ユーザ空間でスキャンを行うプログラムが動作していないことが明らかな場合もタイムアウトまで待つというのは無駄なことです。何らかの方法で、ユーザ空間でスキャンを行うプログラムが動作しているかどうかを判断したいと思うでしょう。

ユーザ空間でスキャンを行うプログラムは、 /sys/kernel/security/scan_file というファイル経由でカーネルとのやり取りを行います。ということは、 /sys/kernel/security/scan_file というファイルをオープンしているスレッドが存在するかどうかを、ユーザ空間でスキャンを行うプログラムが動作しているかどうかの判断材料として使える筈です。

/sys/kernel/security/scan_file というファイルがオープンされる際には scanfile_open() という関数が、クローズされる際には scanfile_release() という関数が呼ばれるようになっています。ということは、これらの関数を修正すれば、 /sys/kernel/security/scan_file というファイルをオープンしているスレッドが存在するかどうかを判断できるようになる筈です。

ということで、以下のように修正します。

static atomic_t scanfile_user_count = ATOMIC_INIT(0);

static int scanfile_open(struct inode *inode, struct file *file)
{
        if ((file->f_mode & (FMODE_WRITE | FMODE_READ)) != (FMODE_WRITE | FMODE_READ))
                return -EINVAL;
        atomic_inc(&scanfile_user_count);
        return 0;
}

static int scanfile_release(struct inode *inode, struct file *file)
{
        if (atomic_dec_and_test(&scanfile_user_count))
                wake_up_all(&scanfile_response_wait);
        return 0;
}

上記のようにすれば、 scanfile_user_count の値が 0 か否かで /sys/kernel/security/scan_file というファイルをオープンしているスレッドが存在するかどうかを判断できるようになります。

また、 scanfile_user_count の値が 0 になったタイミングで wake_up_all() という関数を呼び出すことにより、 scanfile_response_wait というキューの上で待機しているすべてのスレッドに対して、このファイルをオープンしているスレッドが存在しない状態になったことを知らせます。

これにより、以下のように修正することで、ユーザ空間でスキャンを行うプログラムが終了してしまった場合にタイムアウトまで待つという無駄を避けることができるようになります。

static int call_scanfile(const char *pathname)
{
        int error = 0;
        static unsigned int sf_serial;
        struct scanfile_entry entry = { };

        entry.pathname = pathname;
        entry.pathname_len = strlen(entry.pathname) + 1;
        spin_lock(&scanfile_list_lock);
        entry.serial = sf_serial++;
        list_add_tail(&entry.list, &scanfile_waiting_list);
        spin_unlock(&scanfile_list_lock);
        /* Give 60 seconds for user space operation. */
        while (entry.timer < 60) {
        wake_up_all(&scanfile_entry_wait);
        if (wait_event_timeout
            (scanfile_response_wait, entry.answer ||
             !atomic_read(&scanfile_user_count), HZ))
                break;
        else
                entry.timer++;
        }
        spin_lock(&scanfile_list_lock);
        list_del(&entry.list);
        spin_unlock(&scanfile_list_lock);
        switch (entry.answer) {
        case 2: /* Rejected. */
                error = -EPERM;
                break;
        case 1:
                /* Granted. */
                error = 0;
                break;
        default:
                /* Timed out. */
                error = 0;
                break;
        }
        return error;
}

しかし、 wait_event_timeout() という関数は、 SIGKILL シグナルを受信しても、イベントが発生するかタイムアウトが経過するまで制御を戻しません。そのため、 wait_event_timeout() で待機しているスレッドが OOM killer などにより強制終了させられそうになったときに、迅速に反応できなくて困ります。この問題を解消するために、 SIGKILL シグナルを受信したかどうかを判断する fatal_signal_pending() というチェックも行うようにしてみましょう。

static int call_scanfile(const char *pathname)
{
        int error = 0;
        static unsigned int sf_serial;
        struct scanfile_entry entry = { };

        entry.pathname = pathname;
        entry.pathname_len = strlen(entry.pathname) + 1;
        spin_lock(&scanfile_list_lock);
        entry.serial = sf_serial++;
        list_add_tail(&entry.list, &scanfile_waiting_list);
        spin_unlock(&scanfile_list_lock);
        /* Give 60 seconds for user space operation. */
        while (entry.timer < 60) {
                wake_up_all(&scanfile_entry_wait);
                if (wait_event_timeout
                    (scanfile_response_wait, entry.answer ||
                     !atomic_read(&scanfile_user_count), HZ) ||
                    fatal_signal_pending(current))
                        break;
                else
                        entry.timer++;
        }
        spin_lock(&scanfile_list_lock);
        list_del(&entry.list);
        spin_unlock(&scanfile_list_lock);
        switch (entry.answer) {
        case 2: /* Rejected. */
                error = -EPERM;
                break;
        case 1:
                /* Granted. */
                error = 0;
                break;
        default:
                /* Timed out. */
                error = 0;
                break;
        }
        return error;
}

上記のようにすれば、 SIGKILL シグナルを受信した場合でも1秒後にはループを抜けて強制終了することができるようになります。ということで、ファイルのオープン時にオンアクセススキャンを行うための処理を呼び出す ccs_open() 関数を以下のように書くことができます。

/**
 * ccs_open - Check permission for open().
 *
 * @f: Pointer to "struct file".
 *
 * Returns 0 on success, negative value otherwise.
 */
static int ccs_open(struct file *f)
{
        char *pathname;
        int error = 0;

        if (!(ACC_MODE(f->f_flags) & MAY_READ))
                return 0;
        if (!atomic_read(&scanfile_user_count))
                return 0;
        pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                error = call_scanfile(pathname);
                printk(KERN_INFO "open(\"%s\")=%d\n", pathname, error);
                kfree(pathname);
        }
        return error;
}

ACC_MODE() というのは、読み込み/書き込み/読み書きのモードを計算するマクロで、読み込みモードまたは読み書きモードの場合には MAY_READ というビット値を含む値が返却されます。

ここでは、読み込みモードまたは読み書きモードでオープンされた場合で、かつ、 /sys/kernel/security/scan_file というファイルをオープンしているスレッドが存在している場合のみ、アクセスが要求されたファイルのパス名を計算して、 call_scanfile() を呼び出すことでオンアクセススキャンを行うようにしています。

さて、 scanfile_read() と scanfile_write() の処理内容を書けるようになったのでしょうか?いいえ、まだ1つだけ、トラップがあります。

ccs_open() 関数は、ファイルのオープン要求が行われた際に呼ばれる処理です。つまり、ファイルのオープン要求が行われた際に、ファイルに対するオンアクセススキャンを行うためにも、再度ファイルをオープンする必要があり、 ccs_open() 関数が呼ばれてしまうのです。

そのため、このままでは、 /sys/kernel/security/scan_file というファイルをオープンしているスレッドが存在しても、そのスレッドはオンアクセススキャンを行うためにスキャン対象ファイルをオープンすることができないという状態になります。さて、とても困りました。どう対処したらよいでしょうか?

この問題に対処するためには、オンアクセススキャンを行うためのオープン要求とそれ以外のオープン要求とを区別し、前者の場合には ccs_open() 関数は何もしないようにすることが必要になります。

ccs_open() 関数の処理内容を見てみると、オンアクセススキャンを行うスレッドが存在しない場合には ccs_open() 関数は何もしないようにするという、なんだか似たようなことを既にやっていますよね?この条件判断を工夫すれば、オンアクセススキャンを行うスレッドによるオープン要求の場合にも ccs_open() 関数は何もしないようにすることができそうですよね?

さて、どうすれば実現できるでしょう?ヒントは、 scanfile_open() 関数および scanfile_release() 関数の中にあります。

ということで、以下のように修正します。

/* The thread owning /sys/kernel/security/scan_file interface. */
static struct task_struct *scanfile_owner_task;

static int scanfile_open(struct inode *inode, struct file *file)
{
        if ((file->f_mode & (FMODE_WRITE | FMODE_READ)) != (FMODE_WRITE | FMODE_READ))
                return -EINVAL;
        if (cmpxchg(&scanfile_owner_task, NULL, current))
                return -EINVAL;
        return 0;
}

static int scanfile_release(struct inode *inode, struct file *file)
{
        scanfile_owner_task = NULL;
        wake_up_all(&scanfile_response_wait);
        return 0;
}

まず、ファイルのオープン時には、このファイルをオープンしたスレッドの情報を scanfile_owner_task という変数に格納します。その際、 cmpxchg() という関数を用いて scanfile_owner_task という変数の値が NULL でなければ格納しないように制限しています。これにより、このファイルをオープンできるのは1スレッドのみに限定されます。そして、ファイルのクローズ時には、このファイルをオープンしたスレッドの情報を消去します。

上記の修正に伴い、 call_scanfile() 関数と ccs_open() 関数を以下のように修正します。

static int call_scanfile(const char *pathname)
{
        int error = 0;
        static unsigned int sf_serial;
        struct scanfile_entry entry = { };

        entry.pathname = pathname;
        entry.pathname_len = strlen(entry.pathname) + 1;
        spin_lock(&scanfile_list_lock);
        entry.serial = sf_serial++;
        list_add_tail(&entry.list, &scanfile_waiting_list);
        spin_unlock(&scanfile_list_lock);
        /* Give 60 seconds for user space operation. */
        while (entry.timer < 60) {
                wake_up_all(&scanfile_entry_wait);
                if (wait_event_timeout
                    (scanfile_response_wait, entry.answer ||
                     !scanfile_owner_task, HZ) ||
                    fatal_signal_pending(current))
                        break;
                else
                        entry.timer++;
        }
        spin_lock(&scanfile_list_lock);
        list_del(&entry.list);
        spin_unlock(&scanfile_list_lock);
        switch (entry.answer) {
        case 2: /* Rejected. */
                error = -EPERM;
                break;
        case 1:
                /* Granted. */
                error = 0;
                break;
        default:
                /* Timed out. */
                error = 0;
                break;
        }
        return error;
}

static int ccs_open(struct file *f)
{
        char *pathname;
        int error = 0;

        if (!(ACC_MODE(f->f_flags) & MAY_READ))
                return 0;
        if (!scanfile_owner_task || current == scanfile_owner_task)
                return 0;
        pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                error = call_scanfile(pathname);
                printk(KERN_INFO "open(\"%s\")=%d\n", pathname, error);
                kfree(pathname);
        }
        return error;
}

・・・ようやく、カーネル側のすべてのトラップを回避して、 scanfile_read() と scanfile_write() の処理内容を書けるようになりました。長かったですね。

ということで、ここまでの内容を実際に動作確認してみましょう。ユーザ空間でスキャンを行うプログラムがまだできていないため、 /sys/kernel/security/scan_file ファイルをオープンしているスレッドは存在しません。よって、以下のように、 ccs_open() 関数から scanfile_owner_task に関するチェックを無効化することで、常に call_scanfile() 関数が呼ばれるようにしておきましょう。

static int ccs_open(struct file *f)
{
        char *pathname;
        int error = 0;

        if (!(ACC_MODE(f->f_flags) & MAY_READ))
                return 0;
        //if (!scanfile_owner_task || current == scanfile_owner_task)
        //        return 0;
        pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                error = call_scanfile(pathname);
                printk(KERN_INFO "open(\"%s\")=%d\n", pathname, error);
                kfree(pathname);
        }
        return error;
}

無事にコンパイルおよびロードできた場合、ファイルのオープン要求が発生する度に、ディレクトリ部分を含むファイル名とエラーコード(現時点では常に 0 )が表示されるようになる筈です。参考用に akari/test.c の改造結果例を添付しておきます。( test-3.10-3.c が Linux 3.10 用、 test-4.11-3.c が Linux 4.11 用です。)

4.5 ユーザ空間のプログラムを書く。

4.4 では、 /sys/kernel/security/scan_file というインタフェースの雛型を作るところまでやりました。今回は、このインタフェースの中身を実装して、ユーザ空間からこのインタフェースを利用してみます。

まずは、 /sys/kernel/security/scan_file をユーザ空間から実際に見えるようにしてみましょう。以下のように、 ccs_open() 関数から scanfile_owner_task に関するチェックを有効化した上で、 ccs_init() 関数の中から sf_securityfs_init() 関数を呼び出すだけです。

static int __init ccs_init(void)
{
        struct security_operations *ops = probe_security_ops();

        if (!ops)
                goto out;
        ccsecurity_exports.d_absolute_path = probe_d_absolute_path();
        if (!ccsecurity_exports.d_absolute_path)
                goto out;
        sf_securityfs_init();
        ccs_update_security_ops(ops);
        return 0;
out:
        return -EINVAL;
}

static int ccs_open(struct file *f)
{
        char *pathname;
        int error = 0;

        if (!(ACC_MODE(f->f_flags) & MAY_READ))
                return 0;
        if (!scanfile_owner_task || current == scanfile_owner_task)
                return 0;
        pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                error = call_scanfile(pathname);
                printk(KERN_INFO "open(\"%s\")=%d\n", pathname, error);
                kfree(pathname);
        }
        return error;
}

次に、このインタフェースの中身を以下に示します。

static ssize_t scanfile_write(struct file *file, const char __user *buf,
                              size_t count, loff_t *ppos)
{
        char buffer[128];
        struct scanfile_entry *ptr;
        unsigned int serial;
        unsigned int answer;

        if (!count || count >= sizeof(buffer))
                return -EINVAL;
        memset(buffer, 0, sizeof(buffer));
        if (copy_from_user(buffer, buf, count - 1))
                return -EFAULT;
        spin_lock(&scanfile_list_lock);
        list_for_each_entry(ptr, &scanfile_waiting_list, list)
                ptr->timer = 0;
        spin_unlock(&scanfile_list_lock);
        if (sscanf(buffer, "A%u=%u", &serial, &answer) != 2)
                return -EINVAL;
        spin_lock(&scanfile_list_lock);
        list_for_each_entry(ptr, &scanfile_waiting_list, list) {
                if (ptr->serial != serial)
                        continue;
                ptr->answer = (u8) answer;
                /* Remove from scanfile_waiting_list. */
                if (ptr->answer) {
                        list_del(&ptr->list);
                        INIT_LIST_HEAD(&ptr->list);
                }
                break;
        }
        spin_unlock(&scanfile_list_lock);
        wake_up_all(&scanfile_response_wait);
        return count;
}

static unsigned int scanfile_poll(struct file *file, poll_table *wait)
{
        if (list_empty(&scanfile_waiting_list)) {
                poll_wait(file, &scanfile_entry_wait, wait);
                if (list_empty(&scanfile_waiting_list))
                        return POLLOUT | POLLWRNORM;
        }
        return POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM;
}

static ssize_t scanfile_read(struct file *file, char __user *buf, size_t count,
                             loff_t *ppos)
{
        struct scanfile_entry *ptr;
        size_t len = 0;
        char *buffer;

        if (list_empty(&scanfile_waiting_list))
                return 0;
        spin_lock(&scanfile_list_lock);
        list_for_each_entry(ptr, &scanfile_waiting_list, list) {
                len = ptr->pathname_len;
                break;
        }
        spin_unlock(&scanfile_list_lock);
        if (!len)
                return 0;
        buffer = kzalloc(len + 32, GFP_KERNEL);
        if (!buffer)
                return -ENOMEM;
        spin_lock(&scanfile_list_lock);
        list_for_each_entry(ptr, &scanfile_waiting_list, list) {
                if (len == ptr->pathname_len)
                        len = snprintf(buffer, len + 31, "Q%u\n%s",
                                       ptr->serial, ptr->pathname);
                break;
        }
        spin_unlock(&scanfile_list_lock);
        if (!buffer[0])
                len = 0;
        else if (count < len)
                len = -ENOMEM;
        else if (copy_to_user(buf, buffer, len))
                len = -EFAULT;
        kfree(buffer);
        return len;
}

カーネルでは、カーネル空間のメモリとユーザ空間のメモリとは厳密に区別されています。カーネル空間のメモリはページフォールトが発生しないため普通に読み書きができますが、ユーザ空間のメモリはページフォールトが発生する可能性があるため、読み書きするのに専用の関数を使う必要があります。具体的には、カーネル空間からユーザ空間へのコピー(ユーザ空間から見ると read() に対応する処理)を行う際には copy_to_user() という関数を、その逆方向のコピー(ユーザ空間から見ると write() に対応する処理)を行う際には copy_from_user() という関数を使います。

write() 操作については、シリアル番号とスキャン結果だけを受け取れば良いので、バッファサイズとして128バイトあれば十分な筈です。そのため、 kmalloc() で動的に確保せずとも、スタックメモリ上に割り当てできそうです。実際には scanfile_open() で /sys/kernel/security/scan_file をオープンできるスレッドを1個に制限しているため、 scanfile_write() が複数のスレッドから同時に呼ばれる可能性も無いので、スタックメモリ上に割り当てる必要も無いのですが・・・。

でも、 /sys/kernel/security/scan_file をオープンできるのが root ユーザの権限で動いているプロセスだけであるからといって、 write() システムコールに与えられた長さが妥当であるかどうかのチェックを省略してはいけません。カーネル空間でのメモリ破壊は運が良くてもカーネルパニック、運が悪ければシステムの乗っ取り( kernel exploit )に繋がるからです。

read() 操作については、シリアル番号とパス名を渡すので、パス名の長さに応じてバッファサイズを変更する必要があります。そのため、 kmalloc() を用いて動的に確保し、コピーが終わったら kfree() で解放するようにしています。( kzalloc() は、 kmalloc() で割り当てたメモリ領域をゼロクリアしてから返却します。)

poll() 操作については、オンアクセススキャン待ちの対象が無い場合に、無駄に read() 操作を繰り返さないようにするために使います。具体的には、 scanfile_waiting_list リストが空でなかった場合には、 call_scanfile() が scanfile_waiting_list リストに追加するまで待つようにします。

最後の仕上げとして、「通常のファイル」以外を無視するための処理( d_is_file() )と、ウイルスが検知されなかった場合にはメッセージを出力しないようにする処理を加えることで、無駄なメッセージの出力を抑止しておきましょう。

#define d_is_file(dentry) ({ struct inode *inode = d_backing_inode(dentry); \
                        inode && S_ISREG(inode->i_mode); })

static int ccs_open(struct file *f)
{
        char *pathname;
        int error = 0;

        if (!(ACC_MODE(f->f_flags) & MAY_READ))
                return 0;
        if (!scanfile_owner_task || current == scanfile_owner_task)
                return 0;
        if (!d_is_file(f->f_path.dentry))
                return 0;
        pathname = ccs_realpath(&f->f_path);
        if (pathname) {
                error = call_scanfile(pathname);
                if (error)
                        printk(KERN_INFO "open(\"%s\")=%d\n", pathname, error);
                kfree(pathname);
        }
        return error;
}

ということで、改善の余地はいろいろありますが、カーネル側のコードはとりあえず完成です。

今度は /sys/kernel/security/scan_file を使うユーザ空間側のプログラムについて考えてみましょう。

第3章で、 ClamAV のAPIを呼び出すための ex2.c というプログラムを書きました。 ex2.c をベースに、コマンドラインで指定された数だけ繰り返す代わりに、 /sys/kernel/security/scan_file からシリアル番号とパス名を受け取り、スキャン結果を /sys/kernel/security/scan_file に書き出すように修正して ex3.c としてください。 read() と write() でやり取りするデータのフォーマットは scanfile_read() と scanfile_write() を見れば判ります。

・ファイルを読み込み用/読み書き用にオープンするにはどんなフラグを指定すれば良いでしょうか?

・ /sys/kernel/security/scan_file から読み出す準備ができるのを待つにはどのようにすれば良いでしょうか?

・ /sys/kernel/security/scan_file から読み出すにはバッファサイズはどれくらい必要になるでしょうか?

・ /sys/kernel/security/scan_file に書き込むにはどのようにすれば良いでしょうか?

・スキャンが終わったファイルをクローズするにはどのようにすれば良いでしょうか?

必要に応じて、以下のオンラインマニュアルも参考にしてください。

参考用に akari/test.c と ex3.c の改造結果例を添付しておきます。( test-3.10-4.c が Linux 3.10 用、 test-4.11-4.c が Linux 4.11 用です。)どうしても解らない人は、 ex3.c を見て、問題点を考えてみてください。

4.6 動作確認をする。

ex3 を常駐させた状態で、いろんなファイルにアクセスしてみましょう。

gcc -Wall ex3.c -o ex3 -lclamav
./ex3

第3章で ClamAV をビルドした際に生成された検体へのアクセスも試してみましょう。

sha1sum ~/rpmbuild/BUILD/clamav-0.99.2/test/*

うまく動きましたか?期待通り動けば、完成です。


第5章 LSMについて知る。


5.1 LSMの歴史について知る。

歴史については Wikipedia を見たほうが詳しいです。(え?丸投げですか?(笑))

でも、その前に、概要を知るために @IT の記事を見た方が良いと思います。

TOMOYO Linux の作者である熊猫さくらの視点から書かれた歴史としてはセキュリティ&プログラミングキャンプ2011の講義資料を参照してください。

LSMインタフェースは Linux 2.5.27 でメインラインにマージ( https://lwn.net/Articles/5208/ )されました。しかし、 Linux 2.6.24 まではメインラインにマージされたLSMモジュールは SELinux だけでした。「 SELinux は最高であり、なんでも SELinux で実現できる」という考え方が通用するくらい、 SELinux の存在は強力だったのです。LSMインタフェースを削除して SELinux に一本化してしまおうという提案もされるほどでした。しかし、 Linus Torvalds の反対によりLSMインタフェースは削除されずに済み、 Linux 2.6.25 で SMACK がマージされました。その後、 Linux 2.6.30 で TOMOYO Linux が、 Linux 2.6.36 で AppArmor がマージされました。その他に、特定の用途/機能に特化したLSMモジュールとして Linux 3.4 で yama が、 Linux 4.7 で loadpin がマージされました。最近提案されたモジュールとしては

などがありますが、まだマージされていません。

傾向としては、10~20年前なら最もクールな解決策だと考えられていた強制アクセス制御( Mandatory Access Control )のような「完全武装型」の実装は影を潜めて、特定の用途/機能に特化した「軽装備型」が増えてきているようです。熊猫が現在提案中の CaitSith が作られた経緯は、セキュリティ・キャンプ2012の講義資料を参照してください。

今までのところ、LSMモジュールがメインラインにマージされるペースはかなり遅いです。その理由は大きく分けて4つあると考えます。

1つ目は、「その機能、 SELinux (あるいは SMACK / TOMOYO / AppArmor )を使えば実現できるのでは?」という反応です。まぁ、機能面では実現できるのかもしれませんが、「軽装備型」が提案される理由として、「完全武装型」を使いこなせるだけのリソースを割くことができないからという点があります。だから、新規性が無かったとしても、「使いこなせるかどうか」を軸にした提案は続いていくことでしょう。

2つ目は、 Linux 4.2 で複数のLSMモジュールを同時に使えるようにするための第一歩がマージされた(呼び出すべきLSMフックが、リスト構造を用いて管理されるようになった)のですが、現在は「アドホックな対応をするのではなく、どのような組み合わせでも正常に動くようにインフラとして整備する」ことを目指してLSMインタフェースの改造が進められているという点です。 SMACK には SELinux と共通する部分があるため、同時に使う上ではいろいろな制約があり、現在はその制約を解消するための試行錯誤が行われています。果たして、 SELinux と SMACK を同時に使えるようにしたところで、実際に使う人が居るのかどうかは不明ですが。

---------- linux-4.2/security/security.c ----------

#define call_void_hook(FUNC, ...)                               \
        do {                                                    \
                struct security_hook_list *P;                   \
                                                                \
                list_for_each_entry(P, &security_hook_heads.FUNC, list) \
                        P->hook.FUNC(__VA_ARGS__);              \
        } while (0)

#define call_int_hook(FUNC, IRC, ...) ({                        \
        int RC = IRC;                                           \
        do {                                                    \
                struct security_hook_list *P;                   \
                                                                \
                list_for_each_entry(P, &security_hook_heads.FUNC, list) { \
                        RC = P->hook.FUNC(__VA_ARGS__);         \
                        if (RC != 0)                            \
                                break;                          \
                }                                               \
        } while (0);                                            \
        RC;                                                     \
})

---------- linux-4.2/security/security.c ----------

3つ目は、新しく提案されるLSMモジュールが必要とするLSMフックが不足していて、提案/マージできないというケースです。例えば、 PTAGS はスレッドを単位としてコンテキストを管理するため、 Linux 2.6.29 で Copy On Write Credentials が採用されるのと同時に廃止されてしまった security_task_alloc() フックを必要としています。 TOMOYO や CaitSith も security_task_alloc() フックを必要としていたのですが、 PTAGS が提案されたことにより話が動き始め、 Linux 4.12 で復活することができました。

---------- linux-2.6.28/security/security.c ----------
int security_task_alloc(struct task_struct *p)
{
        return security_ops->task_alloc_security(p);
}
---------- linux-2.6.28/security/security.c ----------

---------- linux-4.12/security/security.c ----------
int security_task_alloc(struct task_struct *task, unsigned long clone_flags)
{
        return call_int_hook(task_alloc, 0, task, clone_flags);
}
---------- linux-4.12/security/security.c ----------

4つ目は、甘い審査でLSMモジュールをどんどんマージしてしまうと、肥大化して身動きが取れなくなるという心配です。 Linux 2.6.23 までは、(メインラインに含まれているLSMモジュールは SELinux だけではあったものの)ローダブルカーネルモジュールからLSMインタフェースにアクセスすることができたため、メインラインにマージされそうにない単機能なLSMモジュールでも、比較的容易に使うことができました。しかし、 Linux 2.6.24 で、ローダブルカーネルモジュールからLSMインタフェースにアクセスすることができなくなってしまったため、メインラインにマージされ、かつ、カーネルコンフィグで有効にされていないと使えないという、単機能なLSMモジュールにとっては大変高い障壁が発生してしまいました。そんな障壁を打ち破ったのが、熊猫が開発した AKARI です。なお、2つ目の理由で挙げた試行錯誤がまだ解決していないことにより、ローダブルカーネルモジュールからLSMインタフェースに合法的にアクセスできるようになる日がいつになるのかは見通せません。

---------- linux-2.6.23/security/security.c ----------
int register_security(struct security_operations *ops)
{
        if (verify(ops)) {
                printk(KERN_DEBUG "%s could not verify "
                       "security_operations structure.\n", __FUNCTION__);
                return -EINVAL;
        }

        if (security_ops != &dummy_security_ops)
                return -EAGAIN;

        security_ops = ops;

        return 0;
}

int unregister_security(struct security_operations *ops)
{
        if (ops != security_ops) {
                printk(KERN_INFO "%s: trying to unregister "
                       "a security_opts structure that is not "
                       "registered, failing.\n", __FUNCTION__);
                return -EINVAL;
        }

        security_ops = &dummy_security_ops;

        return 0;
}

EXPORT_SYMBOL_GPL(register_security);
EXPORT_SYMBOL_GPL(unregister_security);
---------- linux-2.6.23/security/security.c ----------

5.2 LSMのハードニングについて知る。

LSMフックの保護という観点では、以下のような変更が行われました。

CONFIG_RANDOMIZE_BASE

Linux 4.8 で CONFIG_RANDOMIZE_BASE ( https://lwn.net/Articles/444556/ )という、カーネルをロードするベースアドレスを起動時にランダムにするためのコンフィグオプションがマージされました。これにより、 AKARI がモジュールのロード時に推定したアドレスと System.map ファイルに記録されている実際のアドレスとの間に差分が発生するようになりました。でも、一律にずれるだけなので、 AKARI にとっての影響はありませんでした。

__ro_after_init

LSMフックは関数ポインタを使用しています。関数ポインタは、変数に関数のアドレスを保持させておいて、その変数が保持しているアドレスの処理を呼び出すという性質上、関数ポインタが改ざんされると、本来呼ばれるべき処理が呼ばれなくなってしまいます。例えば、2008年2月の vmsplice() 脆弱性( CVE-2008-0009/CVE-2008-0010 )では、カーネル内で実行される exploit コードの中に security_ops = &dummy_security_ops; に相当する行を1行追加するだけで、 enforcing モードで動作している SELinux の処理が呼ばれないようにすることが可能でした。

Linux 2.6.24 で、ローダブルカーネルモジュールからLSMインタフェースにアクセスすることが(合法的には)できなくなったため、LSMフックへLSMモジュールを登録する処理はシステムの起動時のみ行えればよいことになります。

Linux 4.6 で __ro_after_init ( https://lwn.net/Articles/676145/ )という、初期化処理の完了後にメモリ領域を read only 化するための属性指定がマージされ、いろいろな変数に対して __ro_after_init 属性が指定されるようになりました。そして、 Linux 4.12 では、LSMインタフェースに対しても __ro_after_init 属性が(条件付きで)付与されるに至りました。なお、「条件付きで」とあるのは、 /etc/selinux/config で SELINUX=disabled を指定することで行われる SELinux の無効化処理は初期化処理の完了後に行われるため、 CONFIG_SECURITY_SELINUX_DISABLE が選択されていない場合のみ、 __ro_after_init 属性が付与されるようになっています。

さて、ローダブルカーネルモジュールからLSMインタフェースにアクセスする AKARI には、 CONFIG_SECURITY_SELINUX_DISABLE が選択されていない限り、対応不可能に陥る大ピンチの到来でしょうか!?いいえ。幸いなことに、 AKARI がターゲットとしているディストリビューションのカーネルでは CONFIG_SECURITY_SELINUX_DISABLE が選択されているため、今のところ、直接的な被害は出ていません。しかし、将来もこの状態が続くかどうかは予想できません。直接的な被害が出る前に、ローダブルカーネルモジュールから合法的にLSMインタフェースにアクセスできるようになって欲しいものです。

でも、初期化処理の完了後にメモリ領域を read only 化する方法があるのなら、そのメモリ領域を再度 read write 化する方法もありそうな気がしますよね?実際、頻繁には更新されない変数に対して、普段は read only にしておき、更新を行う際だけ read write にするための方法が議論されています。結局のところ、カーネル内で動作するコードであれば、何らかの抜け道を使って書き換えすることができてしまうため、上述した 5092.c のような処理が実行できてしまえば、無力化されてしまうものと思われます。今のところ、偶発的な上書きを防ぐくらいの効果しか期待できなさそうです。

__randomize_layout

Linux 4.13 で __randomize_layout ( https://lwn.net/Articles/719732/ )という、構造体のレイアウトをランダム化するための属性指定がマージされ、LSMインタフェースもその対象になりました。

でも、このランダム化はコンパイル時に行われるものであるため、コンパイル済みのカーネルに対しては無力だと推定されます。ということは、ディストリビューションカーネルは、コンパイル済みのバイナリカーネルをパッケージとして配布したものを多くのユーザが使う訳なので、攻撃者にとっては、対象のバイナリカーネルパッケージが決まれば、攻撃コードを書けてしまうのではないかと思われます。 RHEL と CentOS のように、同じソースコードを再コンパイルして配布する場合には、ランダム化により同一の攻撃コードが使えなくなるということかなぁ?

pmalloc allocator

__ro_after_init は静的に割り当てられた変数に対して適用しますが、これを動的に割り当てた変数に対して適用できるようにする pmalloc ( https://lwn.net/Articles/724642/ )というアイデアが提案されています。まだマージされていませんが、用途例として、LSMインタフェース/モジュールが使用するメモリ領域を保護することが挙げられています。


第6章 まとめ 受講者へのメッセージ


Linus Torvalds が「 You security people are insane. I'm tired of this "only my version is correct" crap. 」( https://lkml.kernel.org/r/alpine.LFD.0.999.0710010803280.3579@woody.linux-foundation.org )と言ったように、LSMに関わる人達は相当な頑固者のようです。でも、攻撃から防御するというセキュリティ機能の性格上、いろいろな可能性を考えることが大切だと考えます。その中には、攻撃する側の視点と防御する側の視点だけでなく、機能を提供する側の視点や機能を利用する側の視点も必要でしょう。LSMで難しいのは、コードを書く部分ではありません。コード自体は他のサブシステム(メモリ管理やスケジューリングなど)の規則(いわゆるカーネル内で動作するプログラムのいろいろな約束事)に沿って書けばよいのです。LSMモジュールの実装ごとに、それぞれ考え方(主張)があり、それを理解して適切に設定できるようになるのが難しい部分なのだと思います。この講義では、機能を利用する側が「適切に設定するための労力を割けない」という想定の元、「アクセス可否の判断をユーザ空間で動作するプログラムに丸投げする」という方法で機能を提供する側の苦労を体験してもらいました。

(ソフトウェアに限らない話ではありますが)物事が巨大化/複雑化するのに伴い、全体を把握しないまま役割分担の壁に縛られてしまい、「使い捨ての歯車」になってしまうというのは勿体ないと思っています。いろいろな役割を経験して、それぞれの苦労を知り、自分の役割や立場に縛られずに想像力を働かせる気配りと、問題があると思うことを遠慮なく話し合う勇気を大切にしていただけたら嬉しいです。熊猫さくらは、今の世の中は話し合いを禁止する方向に向かっていると感じているので。

いつの日か、人の意思で予防できる人災に対して資源を浪費するのを止めて、毎年のように発生している地震や大雨などの天災に対して資源を割けるようになりますように。