Reproduced Exploit

Kinto: a front-run leaves 1000 stuck in a wallet with no timestamp

deployContract is a KYC-gated CREATE2 of arbitrary bytecode. It does not set walletTs. A caller deploys the owner's wallet init code at salt 7. createAccount sees code.length > 0 and returns that address without writing walletTs. 1000 tokens minted to the wallet cannot be withdrawn: the later call…

Feb 2024•Other•dos•3 min read

Chain

Other

Category

dos

Date

Feb 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 30496-dos-of-an-account-using-frontrun-mixbytes-none-kinto-markdow. Standalone Foundry PoC and full write-up: 30496-dos-of-an-account-using-frontrun-mixbytes-none-kinto-markdow_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dos/griefing · vuln/data/uninitialized

Key info#

ProtocolKinto
FindingMixBytes Kinto, AuditVault 30496
Vulnerable codeKintoWalletFactory.deployContract
HarmThe owner's withdraw reverts. 1000 tokens stay in the wallet. walletTs is 0. The control sets walletTs and the same withdraw pays 1000
Chainlocal synthetic

TL;DR#

deployContract is a KYC-gated CREATE2 of arbitrary bytecode. It does not set walletTs. A caller deploys the owner's wallet init code at salt 7. createAccount sees code.length > 0 and returns that address without writing walletTs. 1000 tokens minted to the wallet cannot be withdrawn: the later call reverts on walletTs == 0. Creating the account first sets walletTs to the block timestamp, and the same withdraw pays the owner 1000.

The vulnerable code#

SOLIDITY
function deployContract(uint256 amount, bytes memory bytecode, bytes32 salt) external returns (address deployed) {
    require(isKYC(msg.sender), "KYC required");
    require(amount == 0, "amount");
    // @> VULN: CREATE2 may be a KintoWallet and walletTs is never written
    deployed = _create2(salt, bytecode);
}

function createAccount(address owner_, uint256 salt) external returns (address ret) {
    require(isKYC(owner_), "KYC required");
    ret = getAddress(owner_, salt);
    if (ret.code.length > 0) {
        return ret;
    }
    ret = _create2(bytes32(salt), walletInitCode(owner_));
    walletTs[ret] = block.timestamp;
}

withdraw then requires factory.getWalletTimestamp(address(this)) != 0. After the front-run that value is 0, so the call reverts and the balance stays 1000.

Root cause#

Wallet deployment and wallet registration are different paths. CREATE2 of the wallet bytecode occupies the salt, and the path that would have stored walletTs is skipped because the code is already there.

Preconditions#

The caller and the owner both pass the KYC check. The init code is the wallet constructor for this owner, token, and factory. Salt is 7. No timestamp change is required. Both calls happen in one transaction; the front-run is just the earlier call.

Attack walkthrough#

  1. deployContract CREATE2s the wallet. See output.txt. walletTs stays 0.
  2. createAccount hits the existing code and returns.
  3. 1000 tokens are minted to the wallet.
  4. The owner's withdraw(1000) reverts.
  5. The wallet balance is 1000.
  6. The control calls createAccount on a fresh factory. walletTs is non-zero. withdraw returns true and the owner holds 1000.

Diagrams#

flowchart TD A["KYC caller deployContract at salt 7"] --> B["CREATE2 deploys the wallet"] B --> C["walletTs stays 0"] C --> D["createAccount sees code and returns"] D --> E["1000 tokens are sent to the wallet"] E --> F["Owner withdraw reverts"] F --> G["1000 remains in the wallet"]

Impact#

The owner cannot withdraw. 1000 tokens are stuck. Recovery that depends on a registered wallet does not run, because walletTs is 0. The control withdraw succeeds and the owner receives 1000.

Remediation#

Reject wallet init code inside deployContract, or set walletTs when createAccount finds the wallet already deployed.

How to reproduce#

BASH
cd 30496-dos-of-an-account-using-frontrun-mixbytes-none-kinto-markdow_exp
forge test -vv

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.