NSEC22 - NFT API
The write-ups of a mushroomy NFT API, yet another great track at NorthSec 2022
Hey, glad you come to check on the NFT API.
At Ouyaya, we pride ourselves in offering the most revolutionary API on the market to handle NFT transactions. This API features a robust and rigorous tiered authentication ensuring that only authorized parties may use it.
Recently, a now former partner has complained about the quality of our documentation, which has lead to the decision to discontinue it. Instead, in the spirit of openness, we simply provide the code of each service.
Below are described the purpose of each service:
- Chanterelle is dedicated to retrieving the certificate required to connect to further services.
- Morel validates the certificate and credentials. We are confident in your abilities, and therefore will not be providing you credentials.
- Enoki allows you to take part of our almighty NFT API!
- nftapi.ctf:50052 (chanterelle.tar.gz)
- nftapi.ctf:50053 (morel.tar.gz)
- nftapi.ctf:50054 (enoki.tar.gz)
Chanterelle
Chanterelle is the first challenge of the NFT API track. This is the challenge that forced us to look into gRPC and understand how to use it. This is how we tackled this challenge.
We are given an archive with the source of the API, minus the configuration files. Our goal is to get the certificate from the service and continue onto the next API.
A quick request to
nftapi.ctf:50052
gives us little help:
We'll definitely have to look at the source to get somewhere. Hopefully, the imported libs file tells that this API uses gRPC:
Great! But what is actually gRPC? Apparently, it's "a modern open source high performance Remote Procedure Call (RPC) framework that can run in any environment". Well, their description doesn't help us much, but their docs are really straightforward, so it didn't took much time until we understood how to make a basic gRPC client.
The first thing to do is to create a channel to the endpoint, then instanciate a stub of the service while using the channel as the argument. When that is done, we can use that stub to call any function described in the
.proto
file as if it was local. Pretty cool, isn't it?
The
.proto
file describes the service, functions and message types of the endpoint. If we want the flag, we need to call a function that returns a message of type
CA
and that function is
CAPrinter
.
If we take a look at the server implementation in
chanterelle_server.py
, we can see that there is no checks that we need to pass. That's great, we can
This is the code we ended up using to solve this challenge: chanterelle_solver.py
Our code creates the channel and stub, then request the certificate, writes it to
ca.pem
and finally prints the flag.
Morel
Morel is the second challenge of this track. The goal was to acquire the access key so that we may go onto the last challenge.
We start by taking a look at
morel.proto
and find out that we have to call
AuthenticationValidator
to get the flag. Then, we can take a look at
morel_server.py
and find out that it uses an interceptor called
Authentication
, that is probably where we need to take a look.
The class is a bit longer, but this part has all the interesting bits. On line 7, we can see that the access key is stored in
self.access_key
, so we need a way to extract that. Fortunately, a diff between
morel
's and
enoki
's
authentication.py
shows that the only difference is on line 12, with the presence of a format string:
12c12
< f'Access key "{self.received_access_key.format(s=self)}" is invalid.'
---
> 'Access key is invalid.'
{s.access_key}
as our access_key, we still get the access denied, but the key is leaked and we can send the request again, using the newly acquired key, to obtain the flag.
In reality, our solution was a messy bunch of python in the interpreter and a lot of trial and error, but this solver was made using notes from the event and is a bit easier to read: morel_solver.py
Enoki
We are in the endgame now.
We want to get the flag, again. In order to do so, we went ahead and read through the code to figure out what we need to do so. This was time consuming.
After a lot of looking around, we learnt quite a few things:
- a newly created wallet has 100 shiitakoin
- we can only buy mint from users with a level higher than ours
- we figured we needed to acquire 100000 shiitakoin to call
FlagPrinter
- we found a probable race condition in
lib/database.py:buy_mint
: calculating the sha512sum of each character of a username
Let's take a look a closer look at the probable race condition:
def buy_mint(self, username, id_minted):
# Blockchain technology? I have no idea how to implement that,
# but I promised management that the code would be using
# military-grade cryptography.
for c in username:
sha512(username.encode())
buyout = self.get_mint_buyout(id_minted)
seller_username = self.get_mint_username(id_minted)
id_nft = self.get_mint_id_nft(id_minted)
id_wallet = self.get_wallet_id(username)
self.remove_funds(username, buyout)
self.add_funds(seller_username, buyout)
self.set_mint_inactive(id_minted)
self.set_nft_owner(id_nft, id_wallet)
return id_nft
This function calculate the sha512sum of the username, len(username) times. If we look a few lines below, we can see that we begin by removing the funds from the buyer's wallet and adding them to the seller's wallet.
If we take a closer look to
remove_funds
and
add_funds
, we can see that
remove_funds
prevents the wallet from going below 0 shiitakoin:
def remove_funds(self, username, amount):
# Failsafe
self.cur.execute(
'UPDATE wallet \
SET shiitakoin = CASE WHEN shiitakoin - ? < 0 THEN 0 ELSE shiitakoin - ? END \
WHERE username = ?',
(amount, amount, username)
)
def add_funds(self, username, amount):
self.cur.execute(
'UPDATE wallet \
SET shiitakoin = shiitakoin + ? \
WHERE username = ?',
(amount, username)
)
If we can
buy_mint
from ourself at a cost of all of our shiitakoins, we might be able to call
remove_funds
many times in a row before
add_funds
is called, therefore multiplying our shiitakoins amount exponentially.
buy_mint
is called by
MintBuyer
only when a request is authenticated and only if
valid_mint_buy
says we have enough shiitakoins. In
valid_mint_buy
, there's a comment inciting us to buy our own NFTs:
def valid_mint_buy(self, username, id_minted):
funds = self.get_wallet_funds(username)
# Sure, buy your own NFT if that makes you happy...
row = self.cur.execute(
'SELECT id_minted FROM nft_minted \
INNER JOIN nft ON nft.id_nft = nft_minted.id_nft \
INNER JOIN wallet ON nft.id_wallet = wallet.id_wallet \
WHERE id_minted = ? AND buyout <= ? AND active = 1 \
AND (username = ? OR level > ?)',
(id_minted, funds, username, self.get_level_by_username(username))
).fetchone()
return True if row else False
Finally,
Mint_Buyer
is callable from gRPC.
Our chain of attack
-
We create a wallet with a long username using
WalletCreator
-
We fetch the amount of shiitakoin in the wallet using
WalletViewer
-
We create a NFT using
NFTCreator
-
We mint the NFT with a buyout equal to the amount of shiitakoin we have using
NFTMinter
-
We start a number of threads to simultaneously call
MintBuy
and we wait for them to complete - We fetch the amount of shiitakoin again and repeat step 3 to 6 if it's below 100000
-
When we have at least 100000, we ask for the flag using
FlagPrinter
And the associated solver: enoki_solver.py
During the event, we let it run overnight. When we came back in the morning, we had a flag waiting for us.
When I ran it again for the write-up on a local setup (and after some improvements), it managed to reliably generate the coins in about 20 seconds:
$ python enoki_solver.py
Starting with 100 shiitakoin
[ 0][100 threads] 100/100000 shiitakoin
...snip...
[ 0][4 threads] 200/100000 shiitakoin
...snip...
[ 1][3 threads] 400/100000 shiitakoin
...snip...
[ 2][4 threads] 800/100000 shiitakoin
...snip...
[ 2][4 threads] 1600/100000 shiitakoin
...snip...
[ 4][3 threads] 3200/100000 shiitakoin
...snip...
[ 10][5 threads] 6400/100000 shiitakoin
...snip...
[ 16][3 threads] 12800/100000 shiitakoin
...snip...
[ 17][3 threads] 25600/100000 shiitakoin
...snip...
[ 18][4 threads] 51200/100000 shiitakoin
...snip...
[ 22][4 threads] 102400/100000 shiitakoin
flag: flag-6ee0e56f1133cbdffc7c2afa7e7bc0740e1c8182
From what we observed, a username length of 2047 or below gave us the fastest races. Why 2047? We have absolutely no clue. If you happen to know why, we'd love to know!
For the number of buyer threads, it seems that no matter how long we make the username, no more that 10 will successfully run. To prevent spawning threads that will fail, we dynamically adjust the number of buyer threads to only use n+1 threads, where n is the count of successful MintBuyer call of the previous race attempt. That's needlessly complex, but it looks cool .
End notes
There were so many great challenges during the event, but I really liked this track. It made me learn about gRPC and practice multi-threading, which I found was quite challenging. Thanks to the challenge creator, simondotsh , for the track!
Flags
Challenge | Points | Solves | Flag |
---|---|---|---|
NFT API (1/3) | 1 | 30 | flag-1260ef5fb8fadf9375a59b364ecd9514179a9e75 |
NFT API (2/3) | 2 | 19 | flag-8ce525de3c8cc005b2db3e3c4274428540c73b1c |
NFT API (3/3) | 5 | 7 | flag-6ee0e56f1133cbdffc7c2afa7e7bc0740e1c8182 |