NSEC22 - NFT API

nsec22 northsec nsec
Zuyoutoki

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:

  1. Chanterelle is dedicated to retrieving the certificate required to connect to further services.
  2. Morel validates the certificate and credentials. We are confident in your abilities, and therefore will not be providing you credentials.
  3. 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:

Diff
12c12
<                 f'Access key "{self.received_access_key.format(s=self)}" is invalid.'
---
>                 'Access key is invalid.'
If we send {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:

Python
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:

Python
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:

Python
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

  1. We create a wallet with a long username using WalletCreator
  2. We fetch the amount of shiitakoin in the wallet using WalletViewer
  3. We create a NFT using NFTCreator
  4. We mint the NFT with a buyout equal to the amount of shiitakoin we have using NFTMinter
  5. We start a number of threads to simultaneously call MintBuy and we wait for them to complete
  6. We fetch the amount of shiitakoin again and repeat step 3 to 6 if it's below 100000
  7. 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:

Text Only
$ 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

References