Awesome
StarknetCC-CTF
Write up of the StarknetCC-CTF held in Lisbon on November 1st 2022!
Overview
You can find all the information we were given on this PDF.
The process to find a flag was this one:
- Select a challenge
- Download the
files.zip
file. Once unzipped, it should contain a folderpublic
along with thecontracts
anddeploy
folders.contracts
holds the source code for the deployed smart contracts (that you will need to hack). Thedeploy
folder contains a filechal.py
which specifies: how the contract was deployed (constructor arguments etc) as well as the functionchecker
which will be called when you click "get flag" later on. - You are given a
nc IP:port
command to run on your shell. - Once you connected, you have three choices possible:
- Spawn a new instance: You will be given some information such as the private key of the player's address, the player's account contract, the and the exploitable contract's address.
- Kill the ongoing instance. Pretty straightforward!
- Get flag. When you call this, the function
checker()
gets called. If it returns true, you should see a flag printed on your shell. Simply paste this flag on the starknet-cc challenge pas, and you're all good!
Now let's see what these challenges were about!
solveme
Let's start with the simplest one. The code is pretty straightforward: we notice that the function is_solved()
simply returns the value of the storage solved
. To write into solved
, just call the function solve()
. Easy peasy.
An example js script is provided as an example. We went for the js
script because none of our computers worked with cairo-lang 0.10.0 / starknet_py v0.5.2.a .
cairo-intro
To solve this one we understand we need to call solve_challenge()
. To do that, we need to be the owner. The owner storage variable gets modified in the owner_check
function. The write
is protected by an if
condition: to bypass it we need to find a number between 31333333377
and 31333333391
which is divisble by 14. Answer: 31333333388
.
This function isn't external so we can't call it directly. We need to call increase_balance(amount)
which will in turn call owner_check
.
increase_balance
doesn't call owner_check
with amount
, it first checks the balance of the caller and calls owner_check
with amount + balance
. So we need to first retrieve our balance, and then call increase_balance
with 31333333388 - current_balance
.
We can then proceed to call solve_challenge
and flag :)
frozen-finance
The goal is to have a balance of 0.
To do this, we need to call withdraw()
. Withdraw will only work if the balance
is higher than Uint256(46, 7)
. Our balance starts with 50
(see the constructor()
method), so we need to increase our balance by 7 * 2**128 + 46 - 50 + 1
in order to be able to call withdraw.
To increase our balance we need to call deposit
. The deposit function first checks that the .high
part of our deposit is 0 (meaning our deposit can only be as high as 2**128 - 1).
Knowing that, we can compute how many times we will need to call deposit
: simply divide 7 * 2**128 + 46 - 50 + 1
by 2**128 - 1
-> this gives us 7
.
Oh but wait the function deposit ensures that deposits
never reaches MAX_DEPOSITS
which is set to 7
... so it won't work.
Oh but wait! Thanks to Starknet Explorer, we can see that deposits
is never updated... so we don't care about this check!
Just call deposit
with 2**128-1
7 times and then call withdraw()
. You can then flag! gg
magic_encoding
No .cairo
file here, just a simple .json
file. Let's try disassembling it with thoth
.
Interesting! An external function named test_password
? Looks like it first xor
's the password with 12345
and then substracts 19423
. If the result it 0, it writes something somewhere... Let's give it a try?
We're looking for a number x
that would be equal to 19423
when xor
'd with 12345
. Well, let's xor
19423
with 12345
and we should have our answer... 19423 ^ 12345 == 31718
. To make sure we didn't mess up, let's try 31718 ^ 12345 == 19423
. Ok that's correct.
Let's try calling test_password
with it? It worked! We can flag :)
claim-a-punk
The goal of this challenge is to manage to mint 2 punks on the same address.
The openzeppelin folder is probably here for a reason. However, let's first have a quick look at claim_a_punk.cairo
.
There are two big functions: claim
and transferWhitelistSpot
. I guess the function tranfserWhitelistSpot
is also here for a particular reason.. let's have a close look at this function.
This file has lots of comments, it makes our life easier, right? But wait... am I seing this right? The comment says Add "to" to _claimers mapping
but the code is just a repetition of the line just above... ah, those programmers! Too lazy to write the code, they copy/paste and forget to edit...
Ok well this means the recipient of this function is never added to the _claimers
mapping. So this also means that... if the player transfers his whitelist spot to a friend, and his friend transfers it back to the player... then the player has effectively been removed from the _claimers
list. We found it!
Quick recap:
- Claim a punk with the player's account
- Create a second account
- Call
transferWhitelistSpot
with the player's account and transfer it to the second account. - Call
transferWhitelistSpot
with the second account and transfer it to the player's account. - Claim a second punk with the player's account!
- Flag! :)
Pro-tip: Dont't forget to set the maxFee
to 0 when doing a transaction with the second account, otherwise your transaction might fail. Alternatively you can transfer ETH from the player's account to the second account :)
cairo-bid
Oooh this one has 4 different .cairo
files. A lot of code in there... and it follows the good the namespace
convention to avoid storage clashing. Nice. bid_interface.cairo
isn't really interesting... bid_lib.cairo
has a lot of felt <=> Uint256 conversion.. maybe there's something going on with this?
bid.cairo
is the external functions that act mainly as wrappers around the inner functions from bid_lib.cairo
. Interesting. How about utils.cairo
? It holds 3 utility functions: felt_to_uint256
, uint256_to_felt
and assert_251_bit
. This last function should catch your eyes. It asserts that a felt
is 251
bits... but a felt is at most 251
bits anyways. So... it doesn't actually verify anything? It would be useful if it took a Uin256
as input... but with a felt it's useless.
So wait if this function is useless, let's look at where it's called, and let's see if we can abuse it? Oh! It's used in the uint256_to_felt
. So we can basically overflow anything that calls uint256_to_felt
by giving as input a Uint256
that is bigger than felt max.
So let's try and call bid
with... let's say Uint256::MAX? The function check_if_enough_funds
will not error because it does a uin256_signed_le
comparison, so the number we passed in will be negative. The function check_minimal_bid
will not error either because it will call the function is_le_felt
which will in turn, not trigger any error... We will then have bypassed all the checks and will be able to write in the desired storage variables. Easy flagging!
first-come-first-served
The code doesn't look to involved. From chal.py
, it looks like the goal is for the user_balance
to be bigger than max_supply
. A quick look at the claim
function got our cairo senses tingling: the /
operator!!
Indeed we know that the /
operator on prime fields can be a little bit confusing... Proof taken from the cairo doc itself!
We started by playing a bit around by dividing the max supply (178757362047346148211425280000000
) on the playground. Dividing by 2, 3, 4, 5 doesn't yield anything interesting... We continued and found an odd value when reaching 23. Indeed, when dividing max supply by 23, you get 0x3d37a6f4de9bd3fc8590b21642c8590b216432a71ebd61cd959dc604121642d
which is bigger than max supply!
Since the _last_claimer
is nothing but a variable that increments on every call, we simply need to claim 22 times (by creating 22 random accounts), and the 23rd time should indeed make the shared_to_deal
be bigger than max supply! We can then flag! :)
access-denied
Not much code here. IAccount.cairo
is simply the interface; and access_denied.cairo
only has the function solve
. It will write to the storage variable if we manage to create an invalid signature. It checks the signature by calling get_public_key
on the Account contract... so actually, simply deploying a new account which returns 0
for the external get_public_key
method should do the trick?
Indeed it does! Simple comme bonjour!
puzzle-box
Due to some technical difficulties (we couldn't connect to the box), we didn't solve it. Feel free to submit a PR! :)
unique-id
At a first glance, this challenge seams rather tricky. The two files are small, yet finding the mistake requires a sharp eye. Indeed, this is an example of the infamous storage name clash. If you look closely, both proxy.cairo
and implementation_v1.cairo
define a storage variable called owners
. "But they're not defined in the same file so they shouldn't interfere with each other" you say? Oh my sweet summer child... Indeed they do!
So we know that when we write in the owner
storage in implementation_v1.cairo
, we're actaully writing in proxy.cairo
's storage. This is interesting, because the owner
variable is the one used to... upgrade the contract!!
In mintNewId
, we write an Identity
struct in owners
. Since a struct is nothing but ordered felts, we can write anything we want in owners
by using the first element of the structure, i.e. first_name
.
By calling mintNewId
with first_name=1
, we can set owners[caller_address]
to be equal to 1. In proxy.cairo
, we can then call update
, as the only check that is done is a call to see if owners[caller_address]
is equal to 1. By doing this, we can effectively upgrade the implementation!
Ok so let's look at what exactly we need to do (in chal.py
). The checker
function checks whether getIdNumber
returns 31337
.
Next step is straightforward: write an implementation_v2.cairo
which simply returns 31337
got getIdNumber
. An example .cairo
is provided in insert link.
Once you've upgraded the contract, you can flag! gg!
Pro-tip: this bug is supposed to have been fixed with cairo-lang 0.10.0. We're not sure exactly why this challenge was in the CTF given that the instructions clearly mentioned we should be using cairo-lang 0.10.0...
dna
Only the Ledger chads solved it. We tried brute forcing it but didn't manage to bruteforce in time.
Courtesy of the Ledger Team
The dna attack consisted in inverting the function pedersen(candidate, 317)=target
.
As 317 is a constant, and all candidates being lower than 248 bits, the challenge could be reduced to compute g^x= target' which is a well known problem: the discrete logarithm problem (DLP). While intractable for large search space, the number of candidates was only 2^34 (4 candidates per step).
At first glance the exhaustive search can be sped up by starting the search from the following values:
branching_values=[
[27644437,35742549198872617291353508656626642567,359334085968622831041960188598043661065388726959079837],#prune 877
[1046527, 16769023, 1073676287],#prune 16127
[87178291199, 479001599, 39916801],#prune 5039
[433494437, 2971215073, 28657, 514229],
[131071, 2147483647, 524287],#prune 8191
[786433, 746497, 995329, 839809],
[9901],#prune all but 333667
]
which lead to 1296 points, precompute all possible 2^15 small sums, then brute force over 2^25 values using only point addition (faster than multiplication), using cairo-rs and parallelization, the result can be obtained in one hour.
The greatest speed up is obtained using the baby-step, giant-step
method, which is a meet in the middle attack, adapted to
DLP. The idea is to split the search space in two. To simplify, imagine looking for a target sum C, instead of computing 2^34 values, proceed as follow:
- compute 2^17 sum value_i in a subset
A
- compute 2^17 values in a subset
B
and store the subsetB'
, whereB'_j=(target-value_j)
- sort both subsets in log(2^17).
- search a collision between
A
andB'
. - the solution is the preimage in
A
andB
.
Note that using the branching values instead of a balanced split improve the computations. With BS and optimizations, the attack takes less than 10 minutes with a single core python implementation.
(NDLR: blast is a dna search algorithm)
account-obstruction
No one managed to solve it :(