-
Notifications
You must be signed in to change notification settings - Fork 0
fix(lido validator): verify tx calldata not tampered #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
Security hardening to detect and block Lido validator transactions whose calldata has been tampered by appending extra bytes.
- Centralizes parsing + calldata integrity check via parseAndValidateCalldata
- Adds canonical re-encoding comparison (ensureCalldataNotTampered) to detect any trailing/malformed bytes
- Expands test suite with tampering scenarios for all Lido operation types
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/validators/evm/base.validator.ts | Adds reusable calldata integrity helper using re-encoding comparison |
| src/validators/evm/lido/lido.validator.ts | Refactors stake/unstake/claim validation to use centralized parse + tamper check |
| src/validators/evm/lido/lido.validator.test.ts | Adds tests covering tampered calldata for stake, unstake, and claim paths |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| it('should reject stake transaction with appended bytes', () => { | ||
| const tx = { | ||
| to: lidoStEthAddress, | ||
| from: userAddress, | ||
| value: '0xde0b6b3a7640000', | ||
| data: | ||
| '0xa1903eab' + | ||
| referralAddress.slice(2).padStart(64, '0') + | ||
| 'deadbeef', // Extra bytes appended | ||
| nonce: 0, | ||
| gasLimit: '0x30d40', | ||
| gasPrice: '0x4a817c800', | ||
| chainId: 1, | ||
| type: 0, | ||
| }; | ||
|
|
||
| const serialized = JSON.stringify(tx); | ||
| const result = shield.validate({ | ||
| yieldId, | ||
| unsignedTransaction: serialized, | ||
| userAddress, | ||
| }); | ||
|
|
||
| expect(result.isValid).toBe(false); | ||
| expect(result.reason).toContain('No matching operation pattern found'); | ||
| const stakeAttempt = result.details?.attempts?.find( | ||
| (a: any) => a.type === TransactionType.STAKE, | ||
| ); | ||
| expect(stakeAttempt?.reason).toContain('calldata has been tampered'); | ||
| }); |
Copilot
AI
Oct 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Multiple tampering rejection tests (stake, unstake variants, claimWithdrawal, claimWithdrawals) duplicate the same pattern of constructing a tx, serializing, validating, and asserting on the tampered reason; consider extracting a helper (e.g., expectTampered(tx, type)) or using a table-driven test to reduce repetition and make future additions (other functions or tamper cases) simpler.
jdomingos
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Amazing work! Approved but added a suggestion to prepare for future changes! 👏
| /** | ||
| * Parse transaction and validate calldata integrity | ||
| */ | ||
| private parseAndValidateCalldata( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only thing I can think of regarding this PR is that this function could be on the base validator.
We could add the interface as a parameter and make it generic. I see this being very useful for other EVM checks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed :)
jdomingos
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👏 Congrats on this work!
Thanks for addressing my feedback!
🔒 Security Fix: Prevent Calldata Tampering in Lido Validator
Problem
The Lido validator was vulnerable to calldata tampering attacks across all transaction types (stake, unstake, claim). The
ethers.Interface.parseTransaction()method ignores extra bytes appended to the end of calldata, which means an attacker could append arbitrary data to a valid transaction and it would still pass validation.Discovered during smoke testing: Appending
deadbeefto valid stake calldata was incorrectly accepted by the validator.Impact
Root Cause
The
ethers.Interface.parseTransaction()method follows standard ABI decoding behavior where trailing bytes are ignored. After parsing, the validators only validated the decoded arguments (referral address, owner, amounts, etc.) but never verified that the original calldata matched the expected encoded length for those arguments.Example of the vulnerability:
Valid calldata: 0xa1903eab000000000000000000000000371240e80bf84ec2ba8b55ae2fd0b467b16db2be
Tampered calldata (with
deadbeefappended) - was incorrectly accepted: 0xa1903eab000000000000000000000000371240e80bf84ec2ba8b55ae2fd0b467b16db2bedeadbeefSolution
Implemented strict calldata integrity validation using a re-encoding approach:
Added
ensureCalldataNotTampered()helper inBaseEVMValidator:encodeFunctionData()Added
parseAndValidateCalldata()helper inLidoValidator:Applied to all Lido validation methods:
validateStake(): Validatessubmit(address)calldatavalidateUnstake(): ValidatesrequestWithdrawals(uint256[], address)calldatavalidateClaim(): Validates bothclaimWithdrawal(uint256)andclaimWithdrawals(uint256[], uint256[])calldataWhy Re-encoding Works
The
encodeFunctionData()method produces canonical ABI encoding. Any deviation from canonical encoding is detected:Both ethers.js and the EVM use the same canonical encoding rules, so the re-encoded version represents exactly what the smart contract will execute.
Changes
parent/src/validators/evm/base.validator.tsensureCalldataNotTampered()protected helper methodparent/src/validators/evm/lido/lido.validator.tsparseAndValidateCalldata()private helper methodvalidateStake()to use the helper (DRY)validateUnstake()to use the helper (DRY)validateClaim()to use the helper (DRY)parent/src/validators/evm/lido/lido.validator.test.tsTesting
Code Quality
parseAndValidateCalldata()helperSecurity Considerations
This fix ensures complete data integrity by validating that the exact calldata matches the canonical encoding of the parsed and validated arguments. No extra bytes can be hidden in transaction data, regardless of whether the calldata is fixed-length or dynamic-length.
Zero-Trust Principle
This change reinforces Shield's zero-trust validation philosophy:
Future Considerations
The
ensureCalldataNotTampered()method inBaseEVMValidatoris now available for other EVM validators to use, providing a consistent approach to calldata integrity validation across the entire Shield library.Unstake/Claim operations: While the re-encoding approach provides comprehensive protection, additional security review of dynamic-length calldata handling (arrays in
requestWithdrawalsandclaimWithdrawals) is recommended to ensure no edge cases exist in complex ABI encoding scenarios