読者です 読者をやめる 読者になる 読者になる

もろず blog

もろちゃんがITに関しての様々なトピックを解説します


bash の脆弱性 "Shell Shock" のめっちゃ細かい話 その2 (CVE-2014-7169)



f:id:chanmoro999:20141002210219p:plain

※2014/10/3 0:00時点で Shell Shock への修正パッチは4つ公開されています
既に対応済みのシステムでもパッチの漏れがないか注意してください

※2014/10/7 14:00時点で Shell Shock への修正パッチは6個公開されています
既に対応済みのシステムでもパッチの漏れがないか注意してください


先日 ShellShock についての記事を書きましたが、
その後もいろいろと進展があり更にいくつかの脆弱性が検出されました
※前回の記事はコチラ
bash の脆弱性 "Shell Shock" のめっちゃ細かい話 (CVE-2014-6721) - もろず blog


現時点で ShellShock に関わる脆弱性はなんと6個も報告されています
CVE-2014-6271
CVE-2014-6277
CVE-2014-6278
CVE-2014-7169
CVE-2014-7186
CVE-2014-7187


NVD のスコアでは、6つの脆弱性全てに 影響度、攻撃容易性共に 最高ランクの10 (high) が設定されているため、かなり深刻な脆弱性という扱いになっています
NVD - Detail

※その他 ShellShock に関する最新情報はコチラの記事をご確認ください

bashの脆弱性(CVE-2014-6271) #ShellShock の関連リンクをまとめてみた - piyolog



6つの脆弱性を一通り調べてみましたが、全ての脆弱性が同じくらい危険というわけではなさそうです
CVE-2014-6271 以外の脆弱性については、システムをクラッシュさせることにはつながりますが、リモートで任意の bashコードが実行できるというものではないようです


今回は CVE-2014-6271 へのパッチでは対応しきれなかった脆弱性 CVE-2014-7169 の詳細について調査します



CVE-2014-7169 の脆弱性があるバージョンかのチェックはこのコマンドで確認できます

env X='() { (a)=>\' sh -c "out echo not patched"

このときカレントディレクトリに out という名前のファイルが作成され、"not patched" と出力されていたらアウトです

$ ls out 
out
$ cat out 
not patched


この脆弱性は CVE-2014-6271 と違って 簡単に任意の bash コードを実行できるというものではありません



この記事では
1. CVE-2014-7169 の概要
2. ソースコードの入手とパッチの適用
3. 修正箇所について
4. コードが実行されるまでの詳細について
5. まとめ
について説明します


1. CVE-2014-7169 の概要

この脆弱性の正体をものすごく簡単にいうと
bash が起動した直後に実行されるコードの先頭に1文字を付け足すことができる
というのものです

※ある特定の条件を満たすメタ文字のみが対象です


ん?...ヽ( ´_つ`)ノ? という感じですよね



それでは簡単に問題点を説明します


先ほど書いたチェック用のコマンド

env X='() { (a)=>\' sh -c "out echo not patched"

これは以下のように3段階に実行されます

f:id:chanmoro999:20141002210311p:plain

①のコマンド

環境変数にセットされた攻撃用の文字列が書き込まれます

②のコマンド

新規に bash が起動されます

起動時に環境変数のバインドが実行され 環境変数X は "() {"から始まる文字列なので関数としてバインドしようとします
ですが、これはわざと関数バインド時の構文チェックでエラーが発生する文字列になっていて、エラーによって環境変数X のバインドは中断されます

このとき、パース処理で利用するバッファに ">" の文字だけが取り残されてしまいます

③のコマンド

コマンドを実行する時のパース処理で、バッファに取り残された ">" がこのコマンドの文字列と勘違いしてしまい、リダイレクトを含んだコマンドと判定されます

そしてパースされた結果

>out echo not patched

というコマンドが実行されます

リダイレクトが一番左にあってちょっと見慣れないですが、実行結果はこのコマンドと同じになります

echo not patched >out 

リダイレクトは右側に書かないといけないと思っていたのですが、なんと bash の構文ではどちらでも問題ないそうです



結局のところ、

echo not patched

の出力を out というファイルへリダイレクトしているということです



②以降の処理をフローで書くとこんな感じですね
f:id:chanmoro999:20141002210343p:plain

CVE-2014-6271 で修正された箇所とは全く違う部分が狙われていることが分かります


発見者の Tavis Ormandy さんが twitter で報告されていた以下のコマンドのカラクリも、同じように考えれば分かりますね

$env X='() { (a)=>\' sh -c "echo date"; cat echo


注意すべきなのは、この脆弱性を利用して行える操作は bash が起動した直後のコマンドの先頭に1文字付け足せる ということだけです


先ほどのテストコードであれば、

out echo not patched

というコマンドに対して影響を及ぼすことができますが、このコマンドはサーバー側で事前に用意されているものです
外部から環境変数を介してこの部分に任意のコードをセットできるわけではありません

なので、この脆弱性を単体で利用した攻撃は、サーバー側の処理をわざとエラーにさせて、他の攻撃と組み合わせた攻撃を実行される可能性があるものと考えられます


CVE-2014-7169 がどんな性質の脆弱性かがお分かり頂けたでしょうか?


ここからは修正されたソースと、この問題が発生していた原因の調査をしていきます


記事の先頭に戻る

2. ソースコードの入手とパッチの適用

パッチの適用前後のソースを確認したいので bashソースコードを入手します

CVE-2014-7169 への修正パッチのバージョンは 3.2.53 とのことなので、
bash-3.2.52 と bash-3.2.53 のソースを用意します

bash-3.2.52 のソースは "前回の記事" で取得済みなので、bash-3.2.53 のパッチを適用してソースを用意します

$ cp -pr bash-3.2.52 bash-3.2.53
$ cd bash-3.2.53/bash-3.2
$ curl https://ftp.gnu.org/pub/gnu/bash/bash-3.2-patches/bash32-053 | patch -p0


記事の先頭に戻る


3. 修正箇所について

ソースの差分を確認します

GNU で公開されているパッチファイル

http://ftp.gnu.org/pub/gnu/bash/bash-3.2-patches/bash32-053

先ほど入手したソースの差分
$ diff -rq bash-3.2.52 bash-3.2.53
Files bash-3.2.52/bash-3.2/parse.y and bash-3.2.53/bash-3.2/parse.y differ
Files bash-3.2.52/bash-3.2/patchlevel.h and bash-3.2.53/bash-3.2/patchlevel.h differ

以下の2ファイルに修正が入ったことがわかります

  • bash-3.2/parse.y
  • bash-3.2/patchlevel.h

1つづつ内容を見て行きましょう

bash-3.2/parse.y reset_parser ()

parse.y というのは bash で記述できるコマンドの文法が定義された Yacc ファイルです
コマンドがどう解釈されるかは、このファイル内に定義されています

reset_parser () の中で eol_ungetc_lookahead という変数の値を 0 でリセットする処理が追加されています

$ diff bash-3.2.52/bash-3.2/parse.y bash-3.2.53/bash-3.2/parse.y
2505a2506,2507
>   eol_ungetc_lookahead = 0;
> 

reset_parser という関数の名前からは、変数の初期化を行うために使われそうなことが想像できます

bash-3.2/patchlevel.h

これは前回と同様にパッチ番号を表す定数ですね

$ diff bash-3.2.52/bash-3.2/patchlevel.h  bash-3.2.53/bash-3.2/patchlevel.h 
28c28
< #define PATCHLEVEL 52
---
> #define PATCHLEVEL 53


差分の内容から想像すると、何かの変数がリセットされずに残りつづけてしまっていたことが問題だったことが分かります
概要で解説した事象とリンクしていますね


記事の先頭に戻る


4. コードが実行されるまでの詳細について

今回は parse.y 内の処理がメインになりますが、処理が複雑すぎるので超ざっくりな説明にとどめます

parse.y には bash の構文のルールが記述されており、これを元にパースの処理を行っています
Yacc の読み方はこのサイトがわかりやすく参考になりました
第9章 速習yacc


パースの処理は超ざっくり言うと2段階に分かれています

  1. 文字列を解析し単語に分割 ※字句解析
  2. 分割された単語の並び順から構造を判定する ※構文解析


このうち、字句解析の処理に含まれていたバグが CVE-2014-7169 の原因となっています

さっそくソースを見て行きましょう


まず、字句解析を行う関数 yylex () は以下のようなソースになっており、内部で read_token () を呼び出しています

parse.y yylex ()
/* Function for yyparse to call.  yylex keeps track of
   the last two tokens read, and calls read_token.  */
static int
yylex ()
{

// -------- 省略 --------

  two_tokens_ago = token_before_that;
  token_before_that = last_read_token;
  last_read_token = current_token;
  current_token = read_token (READ);
  return (current_token);
}

単語ごとに切り出す処理は read_token () 内に実装されています
character には単語の最初の1文字が格納され、peek_char には次の1文字が先取りされます

parse.y read_token ()
/* Read the next token.  Command can be READ (normal operation) or
   RESET (to normalize state). */
static int
read_token (command)
     int command;
{
  int character;		/* Current character. */
  int peek_char;		/* Temporary look-ahead character. */
  int result;			/* The thing to return. */

// -------- 省略 --------

  /* Okay, if we got this far, we have to read a word.  Read one,
     and then check it against the known ones. */
  result = read_token_word (character);
#if defined (ALIAS)
  if (result == RE_READ_TOKEN)
    goto re_read_token;
#endif
  return result;
}

この関数でチェックしているのは "&&" や "||" のようにメタ文字が2つ並ぶパターンをチェックしているようです
ここで判定できなかった文字列は read_token_word () に解析を任せます


read_token_word () 内でも character には単語の最初の1文字が格納され peek_char には次の1文字が先取りされます
単語に切り出せるまで 1文字づつ文字を先読みしていきます

parse.y read_token_word ()
static int
read_token_word (character)
     int character;
{
// -------- 省略 --------

  /* The current delimiting character. */
  int cd;
  int result, peek_char;
  char *ttok, *ttrans;
  int ttoklen, ttranslen;
  intmax_t lvalue;



ここで今回問題となっていた変数 eol_ungetc_lookahead を見てみましょう
コメントを見ると、複数行にまたがった入力でも正しくスキャンできるように、前の行末の1文字を保持するために使われるようです

parse.y eol_ungetc_lookahead
/* This implements one-character lookahead/lookbehind across physical input
   lines, to avoid something being lost because it's pushed back with
   shell_ungetc when we're at the start of a line. */
static int eol_ungetc_lookahead = 0;


shell_getc () は次の1文字を取得するための関数です
eol_ungetc_lookahead に値が入っていた場合はその値が返されることが分かります

parse.y shell_getc ()
static int
shell_getc (remove_quoted_newline)
     int remove_quoted_newline;
{
  register int i;
  int c;
  unsigned char uc;

  QUIT;

  if (sigwinch_received)
    {
      sigwinch_received = 0;
      get_new_window_size (0, (int *)0, (int *)0);
    }
      
  if (eol_ungetc_lookahead)
    {
      c = eol_ungetc_lookahead;
      eol_ungetc_lookahead = 0;
      return (c);
    }

// -------- 省略 --------

}


次の1文字を先読みをしても、次の文字の前が単語の切れ目と判断される場合もあります
その時は最後に取得した文字のインデックスを1つ戻すために shell_ungetc () を呼び出します

このとき、先読みした文字がその行の最後の文字だった場合に、eol_ungetc_lookahead に先読みした文字が格納されます

parse.y shell_ungetc ()
/* Put C back into the input for the shell.  This might need changes for
   HANDLE_MULTIBYTE around EOLs.  Since we (currently) never push back a
   character different than we read, shell_input_line_property doesn't need
   to change when manipulating shell_input_line.  The define for
   last_shell_getc_is_singlebyte should take care of it, though. */
static void
shell_ungetc (c)
     int c;
{
  if (shell_input_line && shell_input_line_index)
    shell_input_line[--shell_input_line_index] = c;
  else
    eol_ungetc_lookahead = c;
}


さて、ここまで登場した関数でだいたい流れが説明できます

() { (a)=>\

この文字列は以下のように分割されます

f:id:chanmoro999:20141003042453p:plain

">"を先頭の文字として読み込んだとき、次の文字に "\"(バックスラッシュ) を読み込みます
"\" はエスケープ文字と判断されるので、更に次の1文字を探しに行きます

このとき、入力された文字は全て読み込みきっているため、終端を示す "EOF" が取得されます

これにより "\" は "EOF" として扱われるので無視されて、">"が行の末尾と判断されます


入力となる文字はもうないので shell_input_line には null がセットされます
このとき "=" が最後の単語に設定され、">" は eol_ungetc_lookahead に残ったままスキャン処理が終了されます


なぜここで文字を取り残すようになっているかはよく分からないんですが、メタ文字1文字ならそこで単語を分割できる、というような理由っぽいです

">" が取り残される処理のソースは以下です
この時の shell_ungetc () で eol_ungetc_lookahead に ">" がセットされ、スキャン処理が終了されます

parse.y read_token_word ()
      /* When not parsing a multi-character word construct, shell meta-
	 characters break words. */
      if MBTEST(shellbreak (character))
	{
	  shell_ungetc (character);
	  goto got_token;
	}

shellbreak (character) が true になるのは以下の文字のどれかでした
取り残される動作は ">"、"<"しか確認できませんでした

"\t","\n","Space","&","(",")",";","<",">","|"


この直後の構文解析の処理でエラーが発生し、環境変数X のバインド処理は中断されます

そして bash の起動後にコードが実行されるとき reset_parser () が実行されますが、パッチ適用前のソースでは eol_ungetc_lookahead に ">" が残ったままになります
その後に shell_getc () が呼び出されたタイミングで 本来のコマンドとは関係ないはずの ">" が取得されてしまいます


記事の先頭に戻る



5. まとめ

長々と説明しましたが、要は変数の初期化忘れがあったようです

これにより、文字を先読みするために利用していたバッファに文字が残ったまま後続の処理が実行される場合があり問題となっていました

今回は parse.y の内部の処理にバグがあったわけですが、Yacc で記述された処理はかなり複雑で、全てを把握するのは相当難しそうな印象を受けました
検出されている関連した脆弱性はほとんどがパース処理に関係したものなのも分かる気がします



ShellShock はとても影響が大きいので各方面で問題になっていますが、なかなか正確な情報が得られないと思います

なんとなく得体の知れないものというイメージを持ってしまいますが、こういった解説記事を書く事で、少しでもその正体を感じ取ってもらえたら嬉しいです

問題が起きるのは困りますが、ITに関わる人にとっては こういう所に気をつけましょうというのを学ぶきっかけになりますよね


ShellShock の問題はまだ収束していないようなので、引き続き動向には注意しましょう

10/1 に4つめのパッチがリリースされています


記事の先頭に戻る