本文の内容は、2024年2月14日にJASON ANDRESS が投稿したブログ(https://sysdig.com/blog/exploring-syscall-evasion/)を元に日本語に翻訳・再構成した内容となっております。
セキュリティ ツールによる検知を回避する手段としての syscall 回避とその努力に対抗するために私たちが出来ることに焦点を当てた記事をシリーズ化して紹介していきます。このシリーズでは、これが Linux オペレーティングシステムにどのように適用されるかについて説明しますが、これは Windows にも適用できる手法であり、この一部についてはシリーズの後半で触れます。
最初の記事として、bash シェル組み込み関数による syscall回避について説明します。これを読んで「bash で何を回避するの?」と思ったとしても、大丈夫です。最初から説明していきます。
Syscallとは何ですか?
システムコール、一般的にはsyscallと呼ばれるものは、ユーザースペースのアプリケーションとカーネルの間のインターフェースです。そして、カーネルはファイル、ネットワーク、ハードウェアなど、他のリソースとやり取りします。基本的に、セキュリティの観点から見ると、syscallはカーネルのゲートキーパーと考えることができます。
セキュリティツール(Falcoを含む)は、悪意のある活動を監視する際に、システムコールの動向を監視することが一般的です。これは合理的な手法のように思えます。なぜなら、システムコールがカーネルのゲートキーパーであり、セキュリティツールがこれらのシステムコールを監視することで、システム上で発生しているすべての活動を把握できるからです。つまり、悪意のある行為に関わるシステムコールを監視し、それらを利用する不正行為を検知して対処することが可能であると考えられます。しかし、残念ながら、このアプローチには限界が存在します。
システムコールには、多種多様なものがあり、一部は機能の重複するものもあります。たとえば、ファイルを開く場合、 open()
というシステムコールがあり、そのドキュメントはこちらで見ることができます。したがって、システムコールを監視できるセキュリティツールがあれば、 open()
システムコールを監視するだけで、アプリケーションがファイルを開こうとする操作を監視できるはずです。しかし、実際には、そのような単純なものではありません。
open()
ドキュメントの概要を見てみると:
実際には、ファイルを開くために使用できる複数のシステムコールがあります: open()
, creat()
, openat()
, openat2()
などがあります。それぞれは異なる動作を行います。たとえば、 open()
と openat()
の主な違いは、 openat()
によって開かれるファイルのパスが、絶対パスが指定されていない限り、現在の作業ディレクトリを基準として扱われることです。使用されているオペレーティングシステム、該当するアプリケーション、およびファイルに対する操作によって、異なるバリエーションの open システムコールが発生する可能性があります。 open()
のみを監視している場合、求めているアクティビティを全く見ることができないかもしれません。
一般に、セキュリティツールは、execve() システムコールを監視します。execve() システムコールは、プロセスの実行が行われていることを示すシステムコールの一つです(execveat()
, clone()
, fork()
など、似たような性質のものが他にもあります)。これは他のシステムコールほど頻繁に行われるものではないので、リソースの観点から見ると安全です。また、興味深い活動のほとんどがここで行われています。EDRのようなツールの多くは、特にこのシステムコールを監視します。すぐにここで見るように、これは必ずしも最良のアプローチではありません。
悪いシステムコールを監視することはできません。それらはすべて単なるツールです。システムコール自体がシステムをハッキングするのではなく、システムをハッキングするのはシステムコールを利用する人々です。監視すべき多くのシステムコールがあり、さまざまな方法で使用されています。Linuxでは、OSとのやり取りの一般的な方法の1つは、bashやzshなどのシステムシェルを介して行われます。
注:システムコールの完全な*リストをご覧になりたい場合は、syscall manページのドキュメントをご覧ください。このリストには、特定のアーキテクチャーに固有のシステムコールや非推奨になったシステムコールも示されています。 *一定の値の範囲で
システムコールの調査
システムコールが何であるかについていくつかのアイデアが得られたので、実際のシステムコールのいくつかを簡単に見てみましょう。Linux では、システムコールが発生したときにそれを調査するための主要なツールの 1 つがstraceです。このために使用できるツールは他にもいくつかあります ( Sysdigのオープンソースバージョンを含む)。これについては今後の記事で詳しく説明します。strace ユーティリティを使用すると、syscall が実行されているときにそれを監視できます。これは、コマンドの実行時に何が起こっているのかを正確に把握したいときにまさに必要なものです。これを試してみましょう:
1 – テストを実行する新しいディレクトリを作成し、次に touch を使用してその中にファイルを作成します。これにより、strace から返されるものを最小限に抑えることができますが、それでもかなりの量が返されます。
5 – 次に、strace を実行し、ls コマンドを実行するように要求します。これは、あまり多くを行っていない、非常に小規模で厳密に制限されたテストの出力であることに留意してください。コマンドのセットがより複雑になると、さらに多くの syscall が発生することになります。
7execve()
– ここでは、 syscall と ls コマンドが実行されていることがわかります。この特定の syscall は、プログラムの実行を示すため、さまざまな検出ツールによって監視されることがよくあります。この例では、他にも多くのシステムコールが行われていますが、execve()は1つだけであることに注意してください。
8 – ここから下では、ls コマンドの実行をサポートするためにさまざまなシステムコールが実行されていることがわかります。ここでは出力についてはあまり深く掘り下げませんが、さまざまなライブラリが使用されていること、アドレス空間がマップされていること、バイトが読み書きされていることなどが確認できます。
Strace には、ここで説明したものよりもはるかに多くの機能セットがあります。さらに詳しく調べるための適切な出発点は、ドキュメントにあります。
システムコールについて説明したので、システムシェルについて少し説明しましょう。
Linux システムシェル 101
システムシェルは、オペレーティング システムとの対話を可能にするインターフェースです。シェルは本質的にグラフィカルな場合もありますが、シェルという言葉を聞く場合、ほとんどの場合、ターミナルアプリケーションを通じてアクセスされるコマンドライン シェルを指します。シェルはユーザーからのコマンドを解釈し、それを syscall 経由でカーネルに渡します。シェルを使用すると、ネットワーク、ファイル、ハードウェア コンポーネントなど、syscall 経由で利用できるものとして前述したリソースと対話できます。
どの Linux インストールにも、1 つ以上のシェルがインストールされます。一般的なサーバーまたはデスクトップのインストールでは、そのうちの少数がデフォルトでインストールされていることがわかります。コンテナに使用されるディストリビューションなど、意図的に無駄を省いたディストリビューションでは、1 つしか存在しない可能性があります。
ほとんどのディストリビューションでは、動作しているシェル環境について簡単に尋ねることができます。
1 – /etc/shells を読み取ると、システムにインストールされているシェルのリストが得られます。sh
ここでは、利用可能なシェルとして、sh
, bash
, rbash
, dash
, zsh
dash
が表示されます。
注:/etc/shellsの内容は、すべての場合においてシステム上のシェルの完全なリストではありません。これはログインシェルとして使用できるもののリストです。これらは一般的に同じリストですが、状況によって異なる場合があります。
15 – echo $0 を実行すると、現在使用しているシェルを簡単に確認できます。この場合、bash シェルを実行しています。
19 – 別のシェルに切り替えるのは簡単です。シェルのリストにzshが存在することがわかりますので、現在のシェルから単純にzshを実行することで切り替えることができます。
21 – zsh に入ったら、どのシェルにいるかを再度尋ねます。現在は zsh であることがわかります。
25 – 次に zsh を終了し、前のシェルに戻ります。どのシェルにいるかをもう一度確認すると、再び bash であることがわかります。
残りの説明では、bash シェルに焦点を当てて説明します。さまざまなシェルの機能は多少異なりますが、少なくとも大まかには似ています。Bash は、オリジナルの Bourne シェルの代替として設計されたため、「Bourne Again SHell」の略です。Bourne シェルは多くのシステムでもよく見られます。先ほど見たリストの /bin/sh
にあります。
すべてが素晴らしいと言えるかもしれませんが、システムコールの回避が約束されました。気を引き締めてください。もう1つ背景を説明した後、それらの部分について話しましょう。
シェルビルトイン vs. 外部バイナリ
シェルでコマンドを実行すると、次のいくつかのカテゴリのいずれかに分類されます。
- シェルの外部にあるプログラム バイナリである可能性があります(略してバイナリと呼びます)。
- 別のコマンドを指す一種のマクロである aliasにすることもできます。
- ユーザー定義のスクリプトまたはコマンドのシーケンスである 関数にすることができます。
- キーワードにすることができます。一般的な例としては、スクリプトを作成するときに使用する「if」のようなものがあります。
- シェル自体に組み込まれたコマンドである、シェルビルトインの場合もあります。ここでは主にバイナリと組み込みに焦点を当てます。
外部バイナリの識別
ls コマンドをもう一度見てみましょう:
1 – laを実行したときに実行されているコマンドの場所を見るために、whichコマンドを使用できます。-aスイッチを使用してすべての結果を返すようにします。いくつかの結果が表示されますが、これはlsが何であるかではなく、どこにあるかを示しています。
6 – 実行時にlsの背後にあるものをより良く理解するために、typeコマンドを使用できます。再度、-aスイッチを追加してすべての結果を取得します。ここでは、1つのエイリアスと2つのファイルがlsコマンドの背後にあるファイルシステムにあることがわかります。
7 – まず、エイリアスが評価されます。この特定のエイリアスは、実行時にlsの出力を色付けするスイッチを追加します。
8 – その後、ファイルシステムには2つのlsバイナリがあります。どちらが実行されるかは、私たちのパスの順序に依存します。
11 – パスを見てみると、/usr/local/binが/binより前に表示されるので、/usr/local/bin/lsが、シェルにlsと入力したときに実行されるコマンドです。ここで知る必要がある最後の情報は、この特定のlsがどのタイプのコマンドであるかです。
15 – lsを詳しく調べるためにfileコマンドを使用できます。ファイルによれば、この特定のバージョンのlsは64ビットのELFバイナリです。コマンドのタイプに関する私たちの議論を一周して、これによりlsは外部バイナリとなります。
21 – たまたま/binにある他のlsを見ると、同一のファイルと同一のハッシュが見つかります。これは何の魔法でしょうか? /binを調べるためにfileを使用すると、binへのシンボリックリンクであることがわかります。私たちはlsバイナリを2度見ていますが、実際にはファイルのコピーは1つだけです。
シェルビルトインの識別
わずかに触れましたが、シェルのビルトインコマンドはシェル自体のバイナリに組み込まれています。特定のシェルで利用可能なビルトインコマンドはかなり異なる場合があります。それでは、bashで利用可能なものを簡単に見てみましょう。
1 – compgenコマンドは、そのような難解なコマンドラインの技術の一つです。この場合、-bスイッチと一緒に使用します。これは事実上、「すべてのシェルビルトインを表示してください」という意味です。また、出力を列で表示するために少しフォーマットし、その結果の数を表示します。
2 – 出力には、cd、echo、pwdなどの一般的なコマンドが表示されます(さらに、先ほど実行したcompgenコマンドも表示されます)。これらを実行すると、ファイルシステム内の他のバイナリにアクセスする必要はありません。すべて、既に実行中のbashシェル内で実行されます。
17 – これらのコマンドのうち、シェルのビルトインリストに含まれているものがあるからといって、それが他の場所に存在しないわけではありません。再度、echoについて問い合わせるためにtypeコマンドを使用すると、ビルトインリストに含まれているechoについては、typeはそれがシェルのビルトインであることを示しますが、ファイルシステムにあるバイナリも見ることができます。bashからechoを実行すると、ビルトインが得られますが、ビルトインのない別のシェルから実行すると、代わりにファイルシステムのものが得られる場合があります。
重要なのは、このビルトインのセットがbashシェル固有であり、他のシェルは非常に異なる場合があるということです。では、zshのビルトインを簡単に見てみましょう。
1 – Zshにはcompgenがありませんので、別の方法で必要なデータを取得する必要があります。ビルトインの連想配列にアクセスし、zshのすべてのビルトインコマンドが含まれているものを取得し、結果を少し整理して列に配置し、最後に結果の数を取得します。
注: 何を出力したのでしょうか?”% print -roC5 — ${(k)builtins}; echo “Count: ${(k)#builtins}”は少し解釈が難しいかもしれません。それぞれの部分が何を行うかを説明します: %: これはおそらくZshシェルにいることを示しています。 print: これはZshで使用されるテキストを表示するコマンドです。 -roC5: これらはprintコマンドのオプションです。 -r: バックスラッシュをエスケープ文字として扱わない。 -o: 表示されるリストをアルファベット順に並べ替える。 C5: 出力を5列にフォーマットします。 —: これはコマンドのオプションの終わりを示します。これ以降のものは、オプションではなく引数として扱われます。 ${(k)builtins}: これはZshのパラメータ展開です。 ${…}: Zshのパラメータ展開の構文です。 (k): 連想配列のキーをリストするためのフラグです。 builtins: これはすべてのビルトインコマンドが含まれるZshの連想配列を参照します。 echo “Count: ${(k)#builtins}”: この部分のコマンドはビルトインコマンドの数を表示します。 echo: テキストを表示するためのコマンドです。 “Count: “: 表示するテキストです。 ${(k)#builtins}: builtins連想配列内のキーの数を数えます。このコンテキストでは、Zshのすべてのビルトインコマンドを数えることを意味します。 |
簡単に言えば、このコマンドは、Zsh シェルで使用可能なすべてのビルトインコマンドをリストし、それらを 5 つの列にフォーマットして、これらのコマンドの合計数を表示します。 |
ここで、zshにはbashよりも40以上のビルトインがあることがわかります。多くのビルトインがbashで見られるものと同じですが、異なるシェルを扱う際にはビルトインコマンドの可用性を検証することが重要です。私たちは、bashを引き続き使用していくことにします。なぜなら、私たちが遭遇する可能性があるシェルの中でよく使われるものの1つだからですが、これは確かに心に留めておく価値があります。
さて、シェルとシェルのビルトインについて少し理解したところで、これらをどのようにシステムコール回避に使用できるかを見てみましょう。
Bash ビルトインを使用した Syscall 回避テクニック
先に述べたように、システムコールを監視する多くのセキュリティ・ツールは、execve() システムコールを通したプロセスの実行を監視します。ある種のツール設計の観点からは、これは、監視する必要のあるシステムコールの数を制限し、起こっている興味深いことのほとんどを捕らえることができるため、素晴らしい解決策です。例えば、catを使ってファイルの内容を読み出し、straceで何が起こるか見てみましょう:
1 – まず、以前使用したテストファイルに少しのデータをechoして、操作するものを用意します。その後、ファイルをcatして、ファイルの内容を確認します。
5 – これをもう一度行ってみましょう。ただし、今回はstraceで何が起こるかを見てみます。新しいbashシェルを立ち上げて、それをstraceで監視します。今回は、straceがサブプロセスも監視するように-fスイッチも追加します。これにより、出力に少しの余分なノイズが生じますが、新しいシェルで操作しているため、何が起こっているかをより良く把握するために必要です。複数のプロセスを監視しているため、straceは各システムコールの開始時にpid(プロセスID)を指定しています。
6 – ここで、私たちが立ち上げたばかりのbashシェルに対するexecve()システムコールが行われています。bashが起動するにつれて、さまざまなサブプロセスが行われているのが見えます。
34 – ここで、プロンプトに戻されましたが、まだstraceで監視されているシェル内で操作しています。再度ファイルをcatして、出力を見てみましょう。
37 – ここで、catのシステムコールが見られます。コマンドの実行結果も含まれています。これは素晴らしいですよね? straceでコマンドを監視し、その実行を見ることができました。実行したコマンドとその出力を正確に見ることができました。
では、bashのビルトインコマンドを使って少しシェルスクリプトを賢く使ってみましょう。そして、その結果を見てみましょう:
1 – 新しい bash シェルを起動し、strace
以前と同じように で監視します。
3 – これはexecve()
予想どおりの bash シェルのシステムコールです。
31 – そしてプロンプトに戻ります。今回は、cat を使用する代わりに、2 つの bash ビルトインを使用してコマンドを一緒にフランケンシュタインにし、cat の動作を再現します。
while IFS= read -r line; do echo “$line”; done < testfile
これは、bash 組み込み関数の read および echo を使用して、ファイルを 1 行ずつ処理します。read を使用して testfile から各行を変数 line にフェッチし、-r
スイッチを使用してバックスラッシュが文字通り読み取られるようにします。(内部フィールド区切り文字)はIFS=
先頭と末尾の空白を保持します。次に、echo は各行を読み取ったとおりに出力します。
35 – なんと!straceからは何も出力されずにプロンプトに戻ってきました。
プロセスの実行を監視しているときにアクティビティが表示されない場合、どうすればそれを見つけられるでしょうか?
適切な場所で Syscall を探す
問題は、隠れたbashのビルトイン活動を見逃していたのは、間違った場所を見ていたことに主に起因していました。execve()で何も起こっていないので、何も見えませんでした。この特定のケースでは、ファイルが開かれていることがわかっているので、openシステムコールの1つを試してみましょう。この特定のケースでは、openat()を直接見てみることにしますが、以前に議論したopenシステムコールのどれでも良いです。
1 – straceで監視されたbashシェルを再度起動します。今回はexecve()ではなくopenat()に基づいてフィルタリングされています。
2 – ファイルが開かれるのを見ているので、今回はbashが起動するときに何が起こっているかについてかなり異なる見解を見ることができます。
72 – プロンプトに戻ったら、ファイルを読み取るための私たちの隠密なbashスクリプトを実行します。
73 – そして、ここで私たちはファイルが開かれているopenat()システムコールとその結果の出力を見ることができます。
ほとんどの場合、シェルの組み込みからアクティビティを取得できますが、必要なアクティビティを適切な場所で探すことが重要です。 すべてのシステムコールを常に監視できればよいと考えたくなるかもしれませんが、すぐに監視することは不可能になります。 上記の例では、openat() だけをフィルター処理しているときに約 50 行の strace 出力が生成されます。 フィルタリングを完全に外してすべての syscall を監視すると、出力は 1,200 行に膨れ上がります。
これは単一のシェル内で行われ、他には何も行われません。 これを実行中のシステム全体で実行しようとすると、負荷によって溶けて燃える粘液の水たまりになるまでの短期間で指数関数的に増加することになります。 言い換えれば、すべてのシステムコールアクティビティを常に監視する合理的な方法は実際にはありません。 私たちにできる最善のことは、監視する対象を意図的に選択することです。
まとめ
このbashシェルビルトインを使用したシステムコール回避の探求は、システムの相互作用を操作してセキュリティ対策をバイパスするための創造的で微妙な方法のほんの一部を明らかにします。プロセス実行の監視に焦点を当てるセキュリティツールは、その範囲が限られており、システムアクティビティの監視により微妙で包括的なアプローチが必要です。これにより、より高いセキュリティレベルが提供されます。
私たちが作成したcatの機能を再現するためのシンプルな例は、これを完全に回避し、プロセス実行のみを監視しているツールのレーダー下で完全にデータを読み取ることを許しました。残念ながら、これは氷山の一角です。
bashのビルトインを上記のように使用すると、他のツールや攻撃の機能を再現するためにそれらを組み合わせる方法がいくつかあります。Googleで検索すれば、bashのビルトインを使用してリバースシェルを組み立てるよく知られた方法がすぐに見つかります。さらに、さまざまなシェルとそれらが持つビルトインの異なるセットを使用して、実験することができます(これは読者への課題とします)。
このシリーズの次の記事では、システムコール回避の他の方法を見ていきます。さらに学びたい場合は、Falcoの防御回避技術を探索してみてください。