The Blacksmith
Python is weird.
Introduction
This was one of the more interesting Web challenges from this CTF, because it taught me something new about Python and how it handles augmented assignment statements.
The challenge centered around a "market" API, where customers could buy "regular" and "exclusive" items.
The customer's eligibility to purchase exclusive items depends on the customer's tier
, which checks if the customer's fame
and the sum of their loyalty point_history
exceeds 1337.
Exploring the API
The first bug that might have been immediately obvious when visiting the page is that the index page is unauthenticated. Although the customer_id
parameter is checked, a HTTPException
is called but not raised.
This pattern does not repeat itself in any of the other API routes, though. We can see that we in fact need a valid customer_id
to access the rest of the features.
Let's register a new user. Because LOYALTY_SYSTEM_ACTIVE
is set to False
, we are given a RestrictedLoyalty
which is a namedtuple
. This is an immutable data structure. We also start with 5 gold
.
Visiting this endpoint provides us with a new customer ID.
A /battle
endpoint provides a potential way to increase our fame
, but as we saw earlier, LOYALTY_SYSTEM_ACTIVE
is False
so this is not possible.
Since our goal is to purchase the flagsword
, we should take a look at the /buy
endpoint. Since this function is rather long, I'll break it up into parts.
First, we have to provide our customer_id
and a list of items
that we want to buy.
The weapons that we are eligible to purchase depends on our customer tier
. Since we are a regular
plebeian, we can only get to purchase regular weapons. Among the regular weapons, we only have enough gold to buy either a brokensword
or a woodensword
.
If any of the items we are attempting to buy exceeds our available gold
, a 403 Forbidden is returned. The total price of all items is summed up and the loyalty points of the items are stored in a point_history
list.
If there are any loyalty points involved, the code attempts to add the point_history
list to our customer point_history
record, EAFP-style.
Note that because our loyalty object is an immutable namedtuple
, this will definitely raise an exception. In fact, attempting to set any attribute in the namedtuple
will cause an AttributeError
when performing the assignment.
Finally, if we managed to purchase a flagsword
, then we are presented with the flag.
Immutability is Misleading
I didn't manage to spot this bug for quite a while, but luckily this challenge is one that can be solved by fuzzing and logging out as many things as possible.
If we just analyze the behaviour of the application when attempting to set the point_history
, we would quickly find that something weird is going on.
By sending a request to buy a woodensword
(costing 5 gold and having 1 loyalty point) as follows
We see that the AttributeError
is raised as expected, but somehow, our point history has actually been modified!
Wait... what??? I thought the namedtuple
is immutable?
Digging Deeper
I wanted to dig a little deeper to investigate the root cause of this weird behaviour that challenged my Introduction to Programming Python knowledge.
Immutability in Python is tricky - while the tuple itself is immutable, if a tuple contains a mutable object, that object can still be modified in-place. For example, if we have a list
within a tuple
, that list can still be modified in-place using a method such as append
.
But wasn't the code performing assignment instead of an in-place operation? Didn't the exception get raised anyway?
Turns out, all the Introduction to Programming lessons that taught me x += y
was the same as x = x + y
were wrong. Taking a look at Python's documentation on statements, we would see that it is explained that these two statements are not quite the same.
An augmented assignment expression like
x += 1
can be rewritten asx = x + 1
to achieve a similar, but not exactly equal effect. In the augmented version,x
is only evaluated once. Also, when possible, the actual operation is performed in-place, meaning that rather than creating a new object and assigning that to the target, the old object is modified instead.
Hmm... ok, but if the operation is only performed in-place, why raise the error?
I then looked up Python's in-place operators, and found that the +=
operator is just syntactic sugar for the __iadd__
method. Basically, when doing x += y
, we are really doing:
and because some objects like tuples are immutable, it is not guaranteed that the operation would be in-place, so there is still an assignment step regardless of whether the operation was in-place or not.
For list objects, the __iadd__
method (implemented as list_inplace_concat
in the CPython source) is just a wrapper for list_extend
, an in-place method. We see that the original list object is still returned to make the assignment step work.
It is at the assignment step that an error is raised, because the immutable namedtuple
does not support item assignments. But by the time this happens, the list has already been modified.
Back to the Challenge
In order to solve this challenge, we just have to buy the woodensword
1337 times. Note that because our gold amount is checked against total_price
only after the point_history
assignment is attempted, we can just add the woodensword
to our cart 1337 times.
First, we send a request to increase our loyalty point history 1337 times.
Then we could unlock and buy the flagsword
!
Last updated