⚡ Thor vs The Bifrost - Denial of Service (DoS) in Solidity (Gas Griefing Case Study)
Important
Vulnerability: Denial of Service via gas griefing / revert-in-loop
Impact: A single malicious actor (Loki) can block all legitimate users from withdrawing funds
Severity: High
Fix: Pull-payment pattern (users claim individually) → no loops, no griefing
🎬 Story Time
In Thor (2011), Loki destroys the Bifrost so no one can leave Asgard.
Here, Loki doesn’t destroy it - he just clogs it with infinite mischief so no one else can cross.
The vulnerable contract tries to pay everyone in one transaction (push-payment).
Loki deploys a contract that reverts on receiving ETH → the whole loop reverts → Thor and every other Asgardian are stuck forever.
Classic DoS, just like real-world incidents (King of the Ether Throne, some governance reward pools, etc.).
Actors
| Actor | Role |
|---|---|
BifrostBridgeVulnerable | Victim contract with looped payout |
LokiTrickster | Malicious contract that reverts on receive |
| Thor (EOA) | Legitimate user who just wants his ETH |
All source code is available in the GitHub repo.
📌 Vulnerable Contract
contract BifrostBridgeVulnerable { address[] public asgardians; mapping(address => uint256) public vaultOfAsgard;
function enterBifrost(address realmWalker) external payable { require(msg.value > 0, "Bifrost requires Ether toll"); if (vaultOfAsgard[realmWalker] == 0) { asgardians.push(realmWalker); } vaultOfAsgard[realmWalker] += msg.value; }
// ⚠️ The dangerous loop function openBifrost() external { for (uint256 i = 0; i < asgardians.length; i++) { address traveler = asgardians[i]; uint256 tribute = vaultOfAsgard[traveler]; if (tribute > 0) { // Loki reverts here → entire tx fails (bool sent, ) = payable(traveler).call{value: tribute}(""); require(sent, "Bifrost jammed!"); vaultOfAsgard[traveler] = 0; } } }}Warning
One malicious contract in the array = everyone is permanently locked out.
Thor is worthy… but even Mjolnir can’t fix a reverted transaction.
🪄 Loki’s Malicious Contract
contract LokiTrickster { receive() external payable { revert("Loki jams the Bifrost!"); }}Exploit steps
- Loki enters the Bifrost (deposits).
- Heimdall (
openBifrost) tries to send tribute. - Loki’s contract reverts → the entire bridge halts.
- Thor and others are stranded with their funds stuck.
Gas Griefing (Even Without Reverts)
Even if Loki is polite, the loop still grows forever → will eventually hit block gas limit → becomes impossible to call.
Partial executions waste huge gas with zero progress. Real DoS doesn’t need theft - just denial.
Fixed Version - Pull Payment Pattern
contract BifrostBridge_Safe { mapping(address => uint256) public vaultOfAsgard;
function enterBifrost() external payable { require(msg.value > 0, "Bifrost requires Ether toll"); vaultOfAsgard[msg.sender] += msg.value; }
function crossBifrostSafe() external { uint256 tribute = vaultOfAsgard[msg.sender]; require(tribute > 0, "No tribute to cross");
vaultOfAsgard[msg.sender] = 0; // optimistic clear
(bool sent, ) = payable(msg.sender).call{value: tribute}(""); if (!sent) { // restore so user can retry later vaultOfAsgard[msg.sender] = tribute; } }}Loki can only grief himself. Everyone else crosses safely.
Foundry Test (with collapse for brevity)
contract DoSBifrostTest is Test { BifrostBridgeVulnerable bifrostVuln; BifrostBridgeFixed bifrostSafe; LokiTrickster loki; address thor = makeAddr("Thor");
function setUp() public { // Deploy contracts bifrostVuln = new BifrostBridgeVulnerable(); bifrostSafe = new BifrostBridgeFixed(); loki = new LokiTrickster();
// Fund users vm.deal(thor, 1 ether); vm.deal(address(loki), 1 ether);
// Deposit into vulnerable contract bifrostVuln.enterBifrost{value: 1 ether}(thor); bifrostVuln.enterBifrost{value: 1 ether}(address(loki)); }
function test_BifrostJammedByLoki() public { // Expect revert because Loki reverts in openBifrost vm.expectRevert(); bifrostVuln.openBifrost(); }
function test_ThorCrossesSafeBifrost() public { vm.prank(thor); bifrostSafe.enterBifrost{value: 1 ether}();
uint256 beforeBalance = thor.balance; uint256 beforeVault = bifrostSafe.vaultOfAsgard(thor);
vm.prank(thor); bifrostSafe.crossBifrostSafe();
// Assert Thor received funds assertEq(address(thor).balance, beforeBalance + beforeVault); // Assert vault cleared assertEq(bifrostSafe.vaultOfAsgard(thor), 0); }
function test_LokiCannotBlockThor() public { // Loki enters safe contract vm.prank(address(loki)); bifrostSafe.enterBifrost{value: 1 ether}();
// Loki tries to withdraw but reverts internally (simulated in crossBifrostSafe) vm.prank(address(loki)); bifrostSafe.crossBifrostSafe();
// Thor enters and withdraws safely vm.prank(thor); bifrostSafe.enterBifrost{value: 1 ether}();
vm.prank(thor); bifrostSafe.crossBifrostSafe();
// Assert Thor successfully received funds assertEq(address(thor).balance, 1 ether); assertEq(bifrostSafe.vaultOfAsgard(thor), 0); }}(Full test file in the repo - it proves Loki cannot block Thor in the fixed version.)
Auditor’s Checklist
Warning (Quick Audit Checklist)
- Unbounded loops over user-controlled arrays?
- External calls (
call,transfer,send) inside loops? - Push payments instead of pull?
- No gas limit checks or pagination?
→ If any yes → high DoS risk
Recommendations (Best Practices)
- Use pull over push (OpenZeppelin
PullPaymentor manual) - Avoid unbounded loops - paginate or use mappings only
- Test with malicious contracts that revert or consume max gas
- Consider
withdrawpattern + balance tracking
Quiz Time
Exercise (Question 1: What makes the vulnerable contract susceptible to DoS?)
a) Missing reentrancy guard
b) Incorrect access control
c) Unbounded loop with external call in payout function
d) Using msg.sender instead of tx.origin
Answer
Answer: c) Unbounded loop with external call
Explanation: A single malicious recipient can revert and block everyone forever.
Exercise (Question 2: Which pattern completely fixes this DoS vector?)
a) Add a reentrancy guard
b) Use transfer instead of call
c) Pull-payment (users claim individually)
d) Add a timelock
Answer
Answer: c) Pull-payment
Explanation: No loop = no griefing surface. Each user withdraws independently.
Exercise (Question 3: What is the main impact of this attack?)
a) Funds are stolen
b) Users cannot withdraw their funds
c) Contract upgrades itself
d) Owner can drain funds
Answer
Answer: b) Users cannot withdraw their funds
Explanation: Funds remain in contract but become unreachable due to revert.
Tip
All code shown is in the GitHub repository. Feel free to fork and play with it!