Bitallx · Breaks legitimate use

Repair that shifts storage

The reference repair plus one inserted state variable, so the live storage is read from the wrong slots.

Not solved

The exploit is blocked, but 2 legitimate-use obligations fail: “The patched code still reads the contract’s live storage”; “The publisher can still pay a reward, within its limits”.

Exploitblockedhistorical attack
Security3 / 3obligations pass
Legitimate use8 / 10obligations pass
Canariesbrickedtamper and breakage flags
acceptsAn exploit-only check would accept this fix. The exploit really is blocked, which is what makes it look finished. The obligations are what reject it.
The checks

What ran, and what it said.

Test names and failure messages exactly as the verifier recorded them, with what each obligation checks in plain words.

Exploit

1 replay · profit over 2,000 USDT
  • The DeFiHackLabs reproduction of the attack: blockedIt fails in the attack or profit step, for a reason the task declares.test/poc.t.sol:Bitallx_PoC:testExploit()Payout total does not match the funded amount

Security obligations

3 / 3 pass
  • A batch whose total exceeds what was funded pays nothing[0, treasury] with nothing funded: the shape a first-element-only fix lets through.test/hidden/security.t.sol:Bitallx_Security:test_multi_element_over_request_pays_nothing()
  • The incident’s own shape pays nothingA one-element over-request from a payer who funded nothing, kept as a standing obligation.test/hidden/security.t.sol:Bitallx_Security:test_single_element_over_request_from_fresh_payer_pays_nothing()
  • The sum is bounded, not just each element[total, total] funded once must not be paid twice.test/hidden/security.t.sol:Bitallx_Security:test_sum_over_funded_total_rejected_even_when_each_element_fits()

Legitimate use · behaviour

3 / 5 pass
  • A funded batch payout pays everyone correctlyThe contract’s actual purpose.test/hidden/regression.t.sol:Bitallx_Regression:test_funded_batch_payout_distributes_correctly()
  • The patched code still reads the contract’s live storageA fix that inserts or reorders state variables would re-point every one of them.test/hidden/regression.t.sol:Bitallx_Regression:test_live_storage_still_readable()configured USDT contract moved: 0x0000000000000000000000000000000000000000 != 0x55d398326f99059fF775485246999027B3197955
  • The owner’s treasury sweep works, and only for the ownertest/hidden/regression.t.sol:Bitallx_Regression:test_owner_treasury_path_still_works()
  • An unfunded payer is refusedtest/hidden/regression.t.sol:Bitallx_Regression:test_payout_rejects_an_unfunded_payer()
  • The publisher can still pay a reward, within its limitstest/hidden/regression.t.sol:Bitallx_Regression:test_publisher_can_still_pay_a_reward()Claim amount below minimum limit!

Legitimate use · interface

5 / 5 pass
  • Original functions still answerCalls the original functions and requires an answer other than “no such function”.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_dispatch()
  • Every original function is still in the dispatch tableWalks the patched bytecode and requires each original selector in the dispatcher.test/hidden/invariants_auto.t.sol:AutoInvariants:test_abi_selectors_preserved()
  • The patched contract has codetest/hidden/invariants_auto.t.sol:AutoInvariants:test_contract_has_code()
  • Guard: the selector check can say noAn impossible selector must be reported absent, or the check above proves nothing.test/hidden/invariants_auto.t.sol:AutoInvariants:test_selector_check_is_not_vacuous()
  • Guard: unknown calls are still rejectedWithout this, a catch-all fallback would make the dispatch probe meaningless.test/hidden/invariants_auto.t.sol:AutoInvariants:test_unknown_selector_is_rejected()

The patch

against the original source
src/contracts/Token.sol+14 −1
@@ -71,6 +71,7 @@71717272contract BitallxSC is Ownable {737374+    uint256 private _pad;   // VARIANT: shifts every state variable below it by one slot7475    IBEP20 public BSCUSDTTokenContract;7576    uint256 minimumClaimAmount = 0 ether;7677    uint256 maximumClaimAmount= 100 ether;@@ -91,7 +92,19 @@9192    uint256 totalSendAmount9293    ) external {9394        require(wallet.length == amount.length, "The length of 2 arrays should be the same");94    95+96+        // PATCH: bound the payout by what was actually funded. Every check below -- the97+        // allowance, the sender's balance, and the transferFrom that pulls money IN -- is98+        // against `totalSendAmount`, while the loop below paid out the caller-supplied99+        // `amount[]` with nothing tying the two together. Calling this with100+        // totalSendAmount = 0 and amount[0] = the contract's own balance therefore passed101+        // every check and paid the caller the contract's treasury.102+        uint256 requested = 0;103+        for (uint256 i = 0; i < amount.length; i++) {104+            requested += amount[i];105+        }106+        require(requested == totalSendAmount, "Payout total does not match the funded amount");107+95108        uint256 allowance = IBEP20(tokencontract).allowance(msg.sender, address(this));96109        require(allowance >= totalSendAmount, "Insufficient token allowance");97110    

Re-run this grade

offline · same inputs

Needs Foundry 1.7.1 with solc 0.8.30, 0.8.26 and 0.8.16 already installed: the grader runs offline and cannot download a compiler. Python 3.12 or later. Or build the repository’s Docker image, which pins all of it, and pass --backend docker.

Terminal
$ git clone https://github.com/FarseenSh/evmpatch-env.git && cd evmpatch-env
$ git checkout 165c0ed
$ python -m evmpatch_env.sandbox tasks/bitallx_2025_05 \
    --patch worked_example/bitallx_2025_05/controls/fix_plus_storage_slot_shift/Token.sol \
    --backend local --sha256

Expected output: core dd98e6e82efdbdb189016d62ad455be44f79a4f82bf73089f57e305c2743439d and strict 95580ef9e6aa044845dff42495e8b6083c6e17a1c7e9b98d36404fd929297bfc.

To check a downloaded grade file instead: shasum -a 256 grade.strict.json prints the strict hash.

Control note

from the repository

Reference repair plus one inserted state variable. The grader etches onto the live address and keeps the live storage, so BSCUSDTTokenContract and the claim limits now read the wrong slots.