Summary
MembershipERC1155
proxy cannot be upgraded due to incorrect execution flow.
Vulnerability Details
When a DAO membership is created by MembershipFactory
, it is deployed as a MembershipERC1155
proxy, and proxyAdmin is used.
MembershipFactory.sol#L72-L76:
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
membershipImplementation,
@> address(proxyAdmin),
abi.encodeWithSignature("initialize(string,string,string,address,address)", daoConfig.ensname, "OWP", baseURI, _msgSender(), daoConfig.currency)
);
The deployed MembershipERC1155
proxy is expected to be upgraded by proxyAdmin
by calling upgradeAndCall().
ProxyAdmin.sol#L38-L44:
function upgradeAndCall(
ITransparentUpgradeableProxy proxy,
address implementation,
bytes memory data
) public payable virtual onlyOwner {
proxy.upgradeToAndCall{value: msg.value}(implementation, data);
}
The expected upgrade flow would be like:
upgradeAndCall() upgradeToAndCall()
admin --------------------> proxyAdmin --------------------> proxy
Unfortunately, this simply won't work.
When a TransparentUpgradeableProxy
instance is called, the call will be forwarded to _fallback()
function, and only if the caller is the admin of the proxy, the proxy is upgraded.
TransparentUpgradeableProxy.sol#L95-L105:
function _fallback() internal virtual override {
@> if (msg.sender == _proxyAdmin()) {
if (msg.sig != ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
revert ProxyDeniedAdminAccess();
} else {
@> _dispatchUpgradeToAndCall();
}
} else {
super._fallback();
}
}
TransparentUpgradeableProxy.sol#L88-L90:
function _proxyAdmin() internal view virtual returns (address) {
return _admin;
}
The problem is proxyAdmin
is not the admin of the MembershipERC1155
proxy, as can be seen in the constructor of TransparentUpgradeableProxy
, when a proxy instance is deployed, it creates an an associated ProxyAdmin
instance (let's call it proxyAdmin2
) to manage the proxy, so the admin of the MembershipERC1155
proxy is proxyAdmin2
instead of proxyAdmin
.
TransparentUpgradeableProxy.sol#L79-L83:
constructor(address _logic, address initialOwner, bytes memory _data) payable ERC1967Proxy(_logic, _data) {
@> _admin = address(new ProxyAdmin(initialOwner));
ERC1967Utils.changeAdmin(_proxyAdmin());
}
As a result, any call to upgrade a MembershipERC1155
proxy will not be process internally but forwarded to the implementation, and the call will revert.
Therefore, to make MembershipERC1155
proxy actually be upgraded, the flow should be like:
upgradeAndCall() upgradeAndCall() upgradeToAndCall()
admin --------------------> proxyAdmin --------------------> proxyAdmin2 --------------------> proxy
However, we cannot make proxyAdmin
call upgradeAndCall()
in proxyAdmin2
, nor can we transfer proxyAdmin2
ownership to admin.
Please follow the steps to run PoC to verify:
Follow the instructions to add foundry to the project (You may need to change hardhat version to 2.17.2
in package.json);
Create a test file (.t.sol) containing below in /test directory;
Run forge test --mt testAudit_upgradeProxy
.
pragma solidity ^0.8.22;
import {Test} from "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../contracts/dao/MembershipFactory.sol";
import "../contracts/dao/CurrencyManager.sol";
import "../contracts/dao/tokens/MembershipERC1155.sol";
import {DAOType, DAOConfig, DAOInputConfig, TierConfig} from "../contracts/dao/libraries/MembershipDAOStructs.sol";
contract Audit is Test {
address admin = makeAddr("Admin");
address owpWallet = makeAddr("owpWallet");
ERC20 WETH = new MockERC20("Wrapped ETH", "WETH", 18);
ERC20 WBTC = new MockERC20("Wrapped BTC", "WBTC", 8);
ERC20 USDC = new MockERC20("USDC", "USDC", 6);
MembershipFactory membershipFactory;
CurrencyManager currencyManager;
function setUp() public {
vm.startPrank(admin);
currencyManager = new CurrencyManager();
currencyManager.addCurrency(address(WETH));
currencyManager.addCurrency(address(WBTC));
currencyManager.addCurrency(address(USDC));
MembershipERC1155 membershipERC1155Implementation = new MembershipERC1155();
membershipFactory = new MembershipFactory(
address(currencyManager),
owpWallet,
"https://baseuri.com/",
address(membershipERC1155Implementation)
);
vm.stopPrank();
}
function testAudit_upgradeProxy() public {
address creator = makeAddr("Creator");
DAOInputConfig memory daoInputConfig = DAOInputConfig({
ensname: "SPONSORED DAO",
daoType: DAOType.SPONSORED,
currency: address(USDC),
maxMembers: 127,
noOfTiers: 7
});
vm.startPrank(creator);
address daoMemebershipProxy = membershipFactory.createNewDAOMembership(
daoInputConfig,
createTierConfigs(
daoInputConfig.noOfTiers,
ERC20(daoInputConfig.currency).decimals()
)
);
vm.stopPrank();
MembershipERC1155v2 membershipERC1155v2Implementation = new MembershipERC1155v2();
vm.startPrank(admin);
membershipFactory.updateMembershipImplementation(address(membershipERC1155v2Implementation));
ProxyAdmin proxyAdmin = membershipFactory.proxyAdmin();
vm.expectRevert();
proxyAdmin.upgradeAndCall(ITransparentUpgradeableProxy(daoMemebershipProxy), address(membershipERC1155v2Implementation), "");
vm.stopPrank();
}
function createTierConfigs(uint noOfTiers, uint8 decimals) private returns (TierConfig[] memory tiers) {
tiers = new TierConfig[](noOfTiers);
uint price = 1 * 10 ** decimals;
uint power = 1;
for (int i = int(noOfTiers) - 1; i >= 0; --i) {
uint index = uint(i);
tiers[index] = TierConfig({
amount: 2 ** index,
price: price,
power: power,
minted: 0
});
price *= 2;
power *= 2;
}
}
}
contract MockERC20 is ERC20 {
uint8 _decimals;
constructor(
string memory name_,
string memory symbol_,
uint8 decimals_
) ERC20(name_, symbol_) {
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
}
contract MembershipERC1155v2 is MembershipERC1155 {}
Impact
MembershipERC1155 proxy cannot be upgraded.
Tools Used
Manual Review
Recommendations
There are 2 mitigations:
The simplest solution is to declare proxyAdmin
as an address (EOA/Multisig) instead of an ProxyAdmin
instance, then it can call proxyAdmin2
(the address can be accessed by reading the proxy's ERC-1967 admin slot) to upgrade MembershipERC1155
proxy.
Or we can override TransparentUpgradeableProxy
's _proxyAdmin()
in a child contract, and use the child contract to create DAO membership proxy.
contract MembershipERC1155Proxy is TransparentUpgradeableProxy {
constructor(
address _logic,
address initialOwner,
bytes memory _data
) TransparentUpgradeableProxy(_logic, initialOwner, _data) {}
function _proxyAdmin() internal view override returns (address) {
return ProxyAdmin(super._proxyAdmin()).owner();
}
}
MembershipFactory.sol#L72-L76:
- TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
+ TransparentUpgradeableProxy proxy = new MembershipERC1155Proxy(
membershipImplementation,
address(proxyAdmin),
abi.encodeWithSignature("initialize(string,string,string,address,address)", daoConfig.ensname, "OWP", baseURI, _msgSender(), daoConfig.currency)
);