Open code423n4 opened 1 year ago
trust1995 marked the issue as primary issue
trust1995 marked the issue as satisfactory
0xBugsy marked the issue as sponsor confirmed
Maximum 256 length should be enforced so the enconded N(length) value is truthful in addition CheckParams should check if the underlying token matches with the hToken instead of only checking if its an underlying token in the system
trust1995 marked the issue as selected for report
Lines of code
https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/BranchBridgeAgent.sol#L275-L316 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L860-L1174 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgentExecutor.sol#L259-L299 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootBridgeAgent.sol#L404-L426 https://github.com/code-423n4/2023-05-maia/blob/54a45beb1428d85999da3f721f923cbf36ee3d35/src/ulysses-omnichain/RootPort.sol#L276-L284
Vulnerability details
Impact
Adversary can construct an attack vector that let’s him mint arbitrary amount of hToken’s on the Root Chain.
Note
End-to-end coded PoC is at the end of PoC section.
Proof of Concept
Background
The attack will start on a Branch Chain where we have some underlying ERC20
token
and a correspondinghToken
that representstoken
within the omnichain system. ThecallOutSignedAndBridgeMultiple(...)
function is supposed to bridge multiple tokens to a destination chain and also carry the msg.sender so that the tokens can be credited to msg.sender's VirtualAccount. The attacker will call the function with suchDepositMultipleInputParams
_dParams
that take advantage of several weaknesses contained within the function (below is an overview of DepositMultipleInput struct & flow diagram of BranchBridgeAgent).Weakness
#1
is that the supplied array of tokensaddress[] hTokens
in_dParams
is not checked if it exceeds 256, this causes an obvious issue where ifhTokens
length is > 256 the recorded length inpackedData
will be wrong since it's using an unsafe cast to uint8 and will overflow -uint8(_dParams.hTokens.length)
.Weakness
#2
arises in the subsequent internal function_depositAndCallMultiple(...)
, where the only check done on the suppliedhTokens
,tokens
,amounts
&deposits
arrays is if the lengths match, however, there is no check if the length is the same as the one passed earlier to packedData.Lastly, weakness
#3
is thatbridgeOutMultiple(...)
, called within_createDepositMultiple(...)
, allows for supplying any address in thehTokens
array since it only performs operations on these addresses if -_deposits[i] > 0
or_amounts[i] - _deposits[i] > 0
- in other words - if we setdeposits[i] = 0
&amounts[i] = 0
we can supply ANY address inhTokens[i]
.Supplying the attack vector
The attacker will construct such
DepositMultipleInput _dParams
whereaddress[] hTokens
will have a length of 257 where all entries, excepthTokens[1], hTokens[2] & hTokens[3]
, will contain the Branch address of the samehToken
(note that in the examined functions above there is no restriction to supply the samehToken
address multiple times).In a similar way
address[] tokens
will have length of 257, however, here all entries will contain the underlyingtoken
(it is crucial to include the address of the underlyingtoken
to bypass_normalizeDecimals
).Next
uint256[] amounts
will be of length 257 where all entries will contain 0. Similarlyuint256[] deposits
will be of length 257 where all entries will contain 0. In such configuration the attacker is able to supply a malicioushToken
address as per weakness#3
.The crucial part now is that
hTokens[1]
will contain the address of the underlyingtoken
- this is needed to later bypass the params check on the RootChain.hTokens[2] & hTokens[3]
will contain the attacker’s malicious payload address that when converted to bytes and thenuint256
will represent the arbitrary amount of tokens that the attacker will mint (this conversion will happen on the RootChain).This is how the attack vector looks expressed in code.
Essentially what happens now is that the attacker has
packedData
that contains 257hTokens
,tokens
,amounts
&deposits
, however due to weakness#1
the recorded length is 1 and due to weakness#2
and#3
this construction of the Input will reach_peformCal(data)
and the mismatch between the number of entries and the actual number of supplied entries will cause malicious behavior on the RootChain.The attack vector is inline with the general encoding scheme displayed below, the important note is that Length will contain a value of 1 instead of 257 which will disrupt the decoding on the RootBranch. More details about the encoding can be found in
IRootBridgeAgent.sol
.RootBranch receives the attack vector
The entry point for a message on the Root Chain is
anyExecute(bytes calldata data)
inRootBridgeAgent.sol
- this will be called by Multichain’s AnycallExecutor. The function will unpack and navigate the supplied flag 0x06 - corresponding tocallOutSignedAndBridgeMultiple(...)
that was invoked on the Branch Chain.Next
executeSignedWithDepositMultiple(...)
will be invoked residing inRootBridgeAgentExecutor.sol
, which will subsequently call_bridgeInMultiple(...)
, however, the amount of data passed to_bridgeInMultiple(...)
depends on the packed length of thehTokens
array.If we examine closer the constants and check with the encoding scheme -
PARAMS_START_SIGNED = 21
PARAMS_END_SIGNED_OFFSET = 29
PARAMS_TKN_SET_SIZE_MULTIPLE = 128,
Here the intended behavior is that
_data
is sliced in such a way that it removes the flagbytes1(0x06)
and the msg.sender address, hence we start at byte 21, we have 29 to account for the bytes4(nonce), bytes3(chainId) and bytes1(length) (total of 8 bytes, but remember that byte slicing is exclusive of the second byte index) + uint16(length) * 128 for every set ofhtoken
token
amount
&deposit
. What will happen in the attack case is that_data
will be cut short since length will be 1 instead of 257 and_data
will contain length, nonce, chainId and the first 4 entries of the constructedhTokens[]
array.Now
_bridgeInMultiple
will unpack the_dParams
wherenumOfAssets = 1
, hence only 1 iteration, and will populate a set with in reality the first 4 entries of the suppliedhTokens[]
in the attack vector -hTokens[0] = hToken address
,tokens[0] = token address
,amounts[0] = malicious address payload cast to uint256
,deposits[0] = malicious address payload cast to uint256
.Subsequently
bridgeInMultiple(...)
is called inRootBridgeAgent.sol
, wherebridgeIn(...)
is called for every set ofhToken
,token
,amount
&deposit
- one iteration in the attack scenario.bridgeIn(...)
now performs the criticalcheckParams
from theCheckParamsLib
library where if only 1 of 3 conditions istrue
we will have a revert.The first check is revert if
_dParams.amount < _dParams.deposit
- this isfalse
sinceamount
&deposit
are equal to theuint256
cast of thebytes
packing of the malicious address payload.The second check is:
Here it’s true
amount > 0
, however,_dParams.hToken
is the first entryhTokens[0]
of the attack vector’shTokens[]
array, therefore, it is a valid address &isLocalToken(…)
will returntrue
and will be negated by!
which will make the statementfalse
because of&&
, therefore, it is bypassed.The third check is:
here it’s true
deposit > 0
, however,_dParams.token
is the second entryhTokens[1]
of the attack vector’shTokens[]
array, therefore, it is a valid underlying address &isUnderlyingToken(…)
will returntrue
and will be negated by!
which will make the statementfalse
because of&&
, therefore, it is bypassed.Whole
checkParams(…)
Now back to
bridgeIn(...)
in RootBridgeAgent we get theglobalAddress
for_dParams.hToken
(again this is the validhToken[0]
address from Branch Chain) andbridgeToRoot(...)
is called that resides inRootPort.sol
.bridgeToRoot(...)
will check if theglobalAddress
is valid and it is since we got it from the validhTokens[0]
entry in the constructed attack. Then_amount - _deposit = 0
, therefore, no tokens will be transferred and finally the critical lineif (_deposit > 0) mint(_recipient, _hToken, _deposit, _fromChainId)
here_deposit
is the malicious address payload that was packed to bytes and then unpacked and cast touint256
&_hToken
is the global address that we got fromhTokens[0]
back in the unpacking, therefore whatever the value of theuint256
representation of the malicious address is will be minted to the attacker.Coded PoC
Copy the two functions
testArbitraryMint
&_prepareAttackVector
intest/ulysses-omnichain/RootTest.t.sol
and place them in theRootTest
contract after the setup.Execute with
forge test --match-test testArbitraryMint -vv
Result - 800000000 minted tokens for free in attacker’s Virtual Account
Tools Used
Manual inspection
Recommendation
Enforce more strict checks around input param validation on bridging multiple tokens.
Assessed type
Invalid Validation