1001001

73。CTFのWrite-upや技術的な備忘録を書きとめたいです。

HITCON CTF 2017 Secret Server Revenge 解いてみた

HITCON CTF 2017で出題された Secret Server Revenge を海外のWriteupを参考に解いてみたので、自分なりの言葉でまとめてみます。

本番中はSecret Serverに着手し、フラグ半分まで得ましたが、方針が間違っており時間内に全て特定できず。。。後日、時間内に解いたチームメイトにチーム内の勉強会で方針を聞いて解くことができたので、その拡張の問題であるSecret Server Revengeを解いてみました。

 

問題概要

Secret Server同様、Proof of workの後、"Welcome!!"という文字列をAESで暗号化した結果のBase64エンコード文字列が送られてくる。それ以降はこちらから暗号文を送ると、その復号結果の文字列に応じた処理が行われる。
AESの暗号化の際、平文の文字数がブロック長の倍数でない場合はPKCS7という方式でパディングされる。

PKCS7パディング
"Welcome!!" (9文字なので, ブロック長16文字に<b>7文字</b>足りない)
"Welcome!!\x07\x07\x07\x07\x07\x07\x07"

つまり、パディングとしては、"\x01"*1, "\x02"*2, ... , "\x10"*16 が考えられる。

サーバ側で任意のコマンドを実行するには、各コマンドで始まる文字列の暗号文を得る必要がある。
本問題の場合、任意の平文の暗号文を得ることはできず、最初はEnc(pad("Welcome"))しか得ることはできない。しかしながら、「暗号文の先頭に付与された文字列を復号の際のIVに使用する」ということを利用し、暗号文の1ブロック目だけは復号結果を自由に操作することが可能である。
従って、Enc(pad("Welcome"))という暗号文を得られている以上、get-md5やget-tokenというコマンドを実行するのは容易である。

 

unpad関数の脆弱性

unpad関数が脆弱である。先に述べたように、パディングはPKCS7という方式が利用されている。

def unpad(msg):
  return msg[:-ord(msg[-1])]

この処理は間違ってはおらず、パディングが正当なものであれば正当なunpad処理がなされる。しかし、復号結果の末尾がパディングでなくともその文字の数値に応じたunpad処理が行われてしまう。
(本来なら、復号後にパディングの正当性を確認し、末尾がPKCS7形式のパディングでない場合はErrorとする必要があるだろう。)

従って、暗号文の復号結果の末尾を自由に操作することができれば、unpadの脆弱性により復号結果を任意の文字数削ることが可能である。
(これは Secret Serverでも同様の脆弱性だった。)

ここで、tokenの文字数は56文字であり、get-tokenコマンドで暗号化される平文の長さは len("token: "+token) = 63文字 であることから、get-tokenコマンドで得られる暗号文Enc(token)の復号結果の末尾は "\x01" であることが分かっていることを頭に入れておきたい。

方針

Secret Server Revenge は、Secret Serverと比較して大きく二つの違いがある。

  • 1点目は、解読しなければならない暗号文の平文が32文字のフラグ(可読文字)から56文字のトークン(os.urandom)となった。
  • 2点目は、1回のコネクションにおけるリクエスト数に340回までという制限がついた

1点目の変更により、Secret Server よりも多くのリクエスト数が必要になると考えられるが、2点目の変更によりSecret Serverよりも少ないリクエスト数でトークンを解読する必要がある。

また今後扱う表記について以下に述べる。

Enc(a): aを本問題のAESで暗号化した値
md5(a): aのMD5ハッシュの値
token: 56文字のトークン、またはパディングである"\x01"も含んだトークンの値(適宜読み替える)
a|b : 文字列aと文字列bの連結
その他、pythonの記法を使用。

要約

本問題で必要なステップをまとめる。

Step1. (1 リクエスト)

最初に得られるEnc("Welcome")をそのまま送り返し、 Enc("Command not found") を得る

Step2. (1 リクエスト)

IVを操作してget-tokenを実行し、 Enc(token) を得る

Step3. (57 リクエスト)

IV操作とパディング操作により

get-md5|token[:1]
get-md5|token[:2]
...
get-md5|token[:56]
get-md5|token[:57]("\x01"を含む)

を実行させ、

Enc(md5(token[:1]))
Enc(md5(token[:2]))
...
Enc(md5(token[:56]))
Enc(md5(token[:57])) ("\x01"を含む)

を得る

Step4. (Step3の値をうまく使うと 176 リクエスト)
Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) | ("¥x00"*15 + i ) (1ブロック) | Enc(token)[48:](1ブロック)
合計17ブロック(272文字)

の暗号ブロックを用意し、i を変化させることで

32文字unpadされた時のget-md5の結果
33文字unpadされた時のget-md5の結果
...
255文字unpadされた時のget-md5の結果

を得る。

1文字unpad ~ 31文字unpad のパターンを取得しない理由は、この範囲にある暗号ブロックはunpad操作の為に値が変化しているので、取得していても以降の検証処理で役に立たない為である。

Step5. (56 リクエスト + α)

Step4の暗号ブロックの一部を変化させ、

(1) Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) | "¥x00"*16 (1ブロック) | Enc(md5(token[:1])) 
あるいは、
(2) Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) |       IV (1ブロック)         | Enc(md5(token[:1])) 

をサーバに送り、返ってきた値とStep4で得た224個の値を比較することで、何文字unpadされたかをチェックし、

md5(token[:1])[-1] 

を得る。この時、(2)の暗号ブロックを利用した場合はunpadの値がそのまま md5(token[:1])[-1] となるが、(1)の場合は md5(token[:1])[-1] = "\x00" ^ unpad ^ IV[-1] という計算が必要なことに注意。
次にローカルで、

md5(chr(0))[-1] == md5(token[:1])[-1]
md5(chr(1))[-1] == md5(token[:1])[-1]
...
md5(chr(255))[-1] == md5(token[:1])[-1]

をチェックし、トークンの1文字目の候補を得る。
以降1文字目の候補を利用して同様の処理を、Step2で得た値の数(56回)だけ繰り返す。
つまり、

Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) | "¥x00"*16 (1ブロック) | Enc(md5(token[:2])) 
...
Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) | "¥x00"*16 (1ブロック) | Enc(md5(token[:56]))

を送りStep5を繰り返す。

もしもStep4のどの値にもならなかった場合はunpadが1~31の間に入ってしまっている。その場合はunpad操作を行いunpadが32~255の間に入るように調整すると良い。
(ちなみに、unpadが0の時はmsg[:-0](空文字)となるため、command not foundとなる。つまり、返ってきた値がstap4のどの値にもならずにstep1で得た値になった場合、unpadは0になったと言えるが、その場合も上記のようにunpad操作で32~255の間に入るようにやり直しても構わない。)

Step6. (1 リクエスト)
Enc(token)(4ブロック) | "\x00"*16*11(11ブロック) | "¥x00"*16 (1ブロック) | Enc(md5(token[:57]))
最後の暗号ブロックはEnc(md5(token+"\x01"))である

をサーバに送り、返ってきた値とStep4で得た224個の値を比較することで、何文字unpadされたかをチェックし、

md5(token[:57])[-1] 

を得る。この57文字目は"\x01"であると分かっているので、

md5(cand[0]+"\x01")[-1] == md5(token[:57])[-1]
md5(cand[1]+"\x01")[-1] == md5(token[:57])[-1]
...
md5(cand[n]+"\x01")[-1] == md5(token[:57])[-1]

をチェックする。運が悪くない限り、この処理でトークンの候補を一意に絞ることが可能。

ソースコード

以上のステップを行うプログラムを以下に示す。


HITCON CTF 2017 Secret Server Revenge

感想

AES問の解法は、暗号ブロックの操作やパディングオラクルなど、発想の勝負であることが多い。本番でも頭を柔らかくして解いていきたい。
また、この手の問題は解法を文章化するのが非常に難しい。時間があればビジュアル的な説明も追記したい。