NFT hack contest assignments “The Standoff Digital Art”

On November 15–16, the annual cyber battle The Standoff was held in Moscow, which brought together the best teams of defenders and attackers. The global information security conference held an NFT hacking contest called The Standoff Digital Art.

Swap.Net - NFT Aggregator & Exchange
Coinmonks
Published in
7 min readJun 21, 2022

--

ERC1155 standard smartcontracts were prepared for the contest. The owner of each NFT in the collection (there were 6 in total) was a specially prepared vulnerable smart contract. When each smart contract was successfully exploited, the attacker gained possession of the NFT (in the test network). There was also a cash prize for each successful hack. So, what were the vulnerabilities?

Incubator

🎨 Artem Tkach

In the smart contract, we see three external functions: mint(), allowMinting(), and addToWailist(). The goal is to make the smart contract do an NFT transfer through the mint() function, but the canMint variable is declared as false in the constructor. To unlock the mint() function, allowMinting() is present, but it is only available to the owner of the smart contract.

If we carefully study the third function addToWailist(), we see that it declares an uninitialized dynamic list of addresses. In Solidity, if you don’t assign any value to complex data types like array, mapping or struct during initialization, the variable will simply overwrite the first storage slot of the smart contract when using the keyword “storage”.

However, the Solidity developers did not ignore this “feature” of the language and fixed the compiler four years ago so that an error would be returned in such cases. However, the compiler does not always see the overwriting of the storage.

Thus, if you call addToWaitlist(), then the first element of the storage, where the value of the canMint variable is stored, is overwritten. After that, by calling the mint() function, the attacker gets the NFT.

Mine

🎨 Meta Rite

In this task, the StandoffNFT_2 smart contract inherits the Ownable contract, which is a common pattern. You may notice that in the constructor of the main smart contract, the variable owner is assigned the sender’s address. The withdraw() function, which transfers ownership to NFT, has an onlyOwner modifier, allowing only the owner of the smart contract to be called. The onlyOwner code itself is also standard:

modifier onlyOwner() {
require(owner == msg.sender);
_;
}

But what will owner be equal to when onlyOwner() is called? Right, it will equal 0, since the assignment happened in the StandoffNFT_2 contract, not in the Ownable. When we inherit, the owner value in the Ownable will remain untouched. In other words, nothing prevents us from calling setOwner() and then successfully performing withdraw().

Matter

🎨 Desinfo

In the source code of the smart contract, we see the function unlock(), which transfers possession to the NFT when the condition is met:

require(
bytes32(
0x8d8056f94c32675006872f854a6757279eb9a1070660e871535fc7231dc18b30) ==
keccak256(preimage), "invalid preimage"
);

Also notice the comment “we have very secure metadata”, which makes it clear where to look for the preimage. The smart contract of the collection gives us the address where the metadata is stored:

constructor() ERC1155("https://standoff-nft.vercel.app/api/{}.json") {

The address that is passed to the ERC1155 function is the token URI, i.e. it is the address where NFT marketplaces like OpenSea will go for the metadata of each token collection. It seems all you need to do is substitute {} for the token identifier. However, when accessing /api/3.json, we get a 404 error. What is the problem?

The ERC1155 documentation can give you the answer:

The string format of the substituted hexadecimal ID MUST be lowercase alphanumeric: [0–9a-f] with no 0x prefix.

The string format of the substituted hexadecimal ID MUST be leading zero padded to 64 hex characters length if necessary.

In other words, TOKEN_ID must be converted into hexadecimal form and converted to a length of 64 characters with zeros. Instead of /api/3.json we should query /api/0000000000000000000000000000000000000000000000000000000000000000000000000002.json:

By sending the preimage value to the unlock() function, we get the NFT.

Raven

🎨 volv_victory

In the source code of the smart contract, we see two blacklisted and whitelisted mappings with collection addresses. There is also the addCollections function, which accepts these mappings as input, as well as a signature to verify that the mappings were signed by the owner of the smart contract.

Looking at the transaction history on EtherScan, we find an addCollections call with the correct signature.

The _blacklisted address of The Standoff Digital Art collection. This means that we cannot call transfer(), which sends NFT, because it has the following condition:

require(whitelisted[_collection], "collection is not allowed");

But this is not a problem if you carefully examine how the signature is checked in addCollections():

bytes32 hash = keccak256(abi.encodePacked(_whitelisted, _blacklisted));
address signer = hash.toEthSignedMessageHash().recover(_signature);
require(signer == owner, "only owner can add NFT collections");

Two mappings are “glued” using abi.encodePacked(), the resulting mapping is used to read the keccak-hash and the signature is read from that hash. The error here is that abi.encodePacked() is used instead of abi.encode(). There is a significant difference between the two: abi.encodePacked() does not store information about the number of elements during serialization. This means that abi.encodePacked([1,2,3], [4]) and abi.encodePacked([1,2], [3,4]) will return the same result and therefore the same keccak-hash. More details about such hash collisions due to abi.encodePacked() can be found in this article.

Thus, an attacker can reuse the signature of the smart contract owner to change the location of the collection addresses in the blacklisted and whitelisted variables. In other words, instead of calling:

addCollections(
[0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
0x1A92f7381B9F03921564a437210bB9396471050C],
[0x1EBDe1D447752Ef17625c13940bf0218220bED3b], // адрес standoff в blacklisted
signature
)

make the call:

addCollections(
[0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB
0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D
0x1A92f7381B9F03921564a437210bB9396471050C,
0x1EBDe1D447752Ef17625c13940bf0218220bED3b],
[], // blacklisted теперь пустой
signature
)

The signature will be the same and now we can successfully transfer()!

Transformation

🎨 Anomalit Kate

There is only one external transfer() function in a smart contract that transfers NFT to the sender of the transaction, but it can only be called by the owner of the smart contract, which is set in the constructor. Game over?

Anyway, look at the old version of the Solidity compiler, 0.4.25. In that version it was still possible to make a mistake when declaring a constructor, namely to make the constructor an ordinary function.

✅ Right syntax: constructor(IERC1155 _collection) {}

🛑 Wrong syntax: function constructor(IERC1155 _collection) {}

All that was left for the fastest and most attentive participant to do was to send a transaction calling constructor() with the collection address, and then do transfer().

Recharge

🎨 Loit

Going to the NFT address, we don’t see the source code of the smart contract, but there is a curious message in the transaction history:

There is a task located at the specified address. It must somehow allow to get hold of the address where the NFT belongs. Reading the source code, we see the deploy() function, which takes as input the “salt” parameter, which is 4 bytes long. It calls the function of the same name from the Create2 module of OpenZeppelin:

address addr = Create2.deploy(0, salt, getInitCode());

This call publishes a new smart contract to the network using the CREATE2 opcode, which recently appeared in EVM (Ethereum Virtual Machine) as part of the Constantinople hard fork.

Previously, there was only the CREATE opcode; the difference between the two is that the address of a new smart contract created with normal CREATE depends on nonce, which is a number that increases with each new CREATE call, while the addresses of smart contracts created with CREATE2 depend on a user-controlled salt value, which makes the addresses of new smart contracts known in advance.

A smart contract that can be deposited in this way is called NFTOwner. It has a constructor in which the owner is tx.origin, i.e. the original sender of the transaction, and a transfer() function that passes the NFT. All this makes it clear that we need to guess the salt and place the contract at the exact address that owns the NFT.

The task is easy, since we only need to go through 4 bytes. The result of the bruteforcing is “aZy5”. By calling deploy() with this value we take the NFT.

Results

As many as 5 NFTs were cracked by Alexey Byhun in the first hours after the start of the contest. The last NFT went to Alexei Egorov, who solved the salt challenge.

Join Coinmonks Telegram Channel and Youtube Channel learn about crypto trading and investing

Also, Read

--

--