diff --git a/src/validators/evm/base.validator.ts b/src/validators/evm/base.validator.ts index eef6c1e..0f0c53f 100644 --- a/src/validators/evm/base.validator.ts +++ b/src/validators/evm/base.validator.ts @@ -1,6 +1,7 @@ import { BaseValidator } from '../base.validator'; import { ValidationResult } from '../../types'; import { isDefined, isNonEmptyString } from '../../utils/validation'; +import { ethers } from 'ethers'; export interface EVMTransaction { to: string | null; @@ -100,4 +101,74 @@ export abstract class BaseEVMValidator extends BaseValidator { } return null; } + + protected parseAndValidateCalldata( + tx: EVMTransaction, + iface: ethers.Interface, + ): { parsed: ethers.TransactionDescription } | { error: ValidationResult } { + try { + const parsed = iface.parseTransaction({ + data: tx.data ?? '0x', + value: tx.value, + }); + + if (!isDefined(parsed)) { + return { + error: this.blocked('Failed to parse transaction data'), + }; + } + + // Check for tampering + const tamperErr = this.ensureCalldataNotTampered( + tx.data ?? '0x', + iface, + parsed, + ); + + if (tamperErr) { + return { error: tamperErr }; + } + + return { parsed }; + } catch (error) { + return { + error: this.blocked('Invalid transaction data', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + } + + protected ensureCalldataNotTampered( + originalCalldata: string, + iface: ethers.Interface, + parsedTx: ethers.TransactionDescription, + ): ValidationResult | null { + try { + // Re-encode the function call with the parsed arguments + const expectedCalldata = iface.encodeFunctionData( + parsedTx.name, + parsedTx.args, + ); + + // Normalize both to lowercase for comparison + const normalizedOriginal = originalCalldata.toLowerCase(); + const normalizedExpected = expectedCalldata.toLowerCase(); + + // Check if they match exactly + if (normalizedOriginal !== normalizedExpected) { + return this.blocked('Transaction calldata has been tampered with', { + expectedLength: expectedCalldata.length, + actualLength: originalCalldata.length, + lengthDifference: originalCalldata.length - expectedCalldata.length, + }); + } + + return null; + } catch (error) { + return this.blocked('Failed to validate calldata integrity', { + error: error instanceof Error ? error.message : String(error), + }); + } + } } diff --git a/src/validators/evm/lido/lido.validator.test.ts b/src/validators/evm/lido/lido.validator.test.ts index 3c749ce..e4410d9 100644 --- a/src/validators/evm/lido/lido.validator.test.ts +++ b/src/validators/evm/lido/lido.validator.test.ts @@ -1,5 +1,6 @@ import { Shield } from '../../../shield'; import { TransactionType } from '../../../types'; +import { ethers } from 'ethers'; describe('LidoValidator via Shield', () => { const shield = new Shield(); @@ -166,6 +167,37 @@ describe('LidoValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('Invalid referral address'); }); + 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'); + }); + it('should validate STAKE transaction', () => { const real = { from: '0x4546fC1b71375eA0fa4D8cA32B9F2C2ED4FB2E82', @@ -341,6 +373,77 @@ describe('LidoValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('Withdrawal amounts array is empty'); }); + it('should reject unstake transaction with appended bytes', () => { + const tx = { + to: lidoWithdrawalQueueAddress, + from: userAddress, + value: '0x0', + data: + '0xd6681042' + + '0000000000000000000000000000000000000000000000000000000000000040' + + '000000000000000000000000' + + userAddress.slice(2) + + '0000000000000000000000000000000000000000000000000000000000000001' + + '0000000000000000000000000000000000000000000000000de0b6b3a7640000' + + 'cafebabe', // Extra bytes + nonce: 0, + gasLimit: '0x493e0', + 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 unstakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.UNSTAKE, + ); + expect(unstakeAttempt?.reason).toContain('calldata has been tampered'); + }); + + it('should reject unstake with multiple amounts and appended bytes', () => { + // requestWithdrawals([1 ETH, 2 ETH], owner) + extra bytes + const tx = { + to: lidoWithdrawalQueueAddress, + from: userAddress, + value: '0x0', + data: + '0xd6681042' + + '0000000000000000000000000000000000000000000000000000000000000040' + + '000000000000000000000000' + + userAddress.slice(2) + + '0000000000000000000000000000000000000000000000000000000000000002' + // 2 amounts + '0000000000000000000000000000000000000000000000000de0b6b3a7640000' + // 1 ETH + '0000000000000000000000000000000000000000000000001bc16d674ec80000' + // 2 ETH + '12345678', // Extra bytes + nonce: 0, + gasLimit: '0x493e0', + gasPrice: '0x4a817c800', + chainId: 1, + type: 0, + }; + + const serialized = JSON.stringify(tx); + const result = shield.validate({ + yieldId, + unsignedTransaction: serialized, + userAddress, + }); + + expect(result.isValid).toBe(false); + const unstakeAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.UNSTAKE, + ); + expect(unstakeAttempt?.reason).toContain('calldata has been tampered'); + }); + it('should validate UNSTAKE transaction', () => { const real = { from: '0x034A21184A8832EBa5D9fcD61D533D0d641ECe12', @@ -563,6 +666,77 @@ describe('LidoValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('arrays length mismatch'); }); + it('should reject claimWithdrawal with appended bytes', () => { + const requestId = 123n; + const encodedParams = requestId.toString(16).padStart(64, '0'); + + const tx = { + to: lidoWithdrawalQueueAddress, + from: userAddress, + value: '0x0', + data: '0xf8444436' + encodedParams + '12345678', // Extra bytes + 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 claimAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.CLAIM_UNSTAKED, + ); + expect(claimAttempt?.reason).toContain('calldata has been tampered'); + }); + + it('should reject claimWithdrawals with appended bytes', () => { + // Generate VALID calldata using ethers + const iface = new ethers.Interface([ + 'function claimWithdrawals(uint256[] _requestIds, uint256[] _hints)', + ]); + + const validCalldata = iface.encodeFunctionData('claimWithdrawals', [ + [1n, 2n], // requestIds + [100n, 200n], // hints + ]); + + // Append malicious bytes + const tamperedCalldata = validCalldata + 'cafebabe'; + + const tx = { + to: lidoWithdrawalQueueAddress, + from: userAddress, + value: '0x0', + data: tamperedCalldata, + 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); + const claimAttempt = result.details?.attempts?.find( + (a: any) => a.type === TransactionType.CLAIM_UNSTAKED, + ); + expect(claimAttempt?.reason).toContain('calldata has been tampered'); + }); + it('should validate batch CLAIM_UNSTAKED transaction', () => { const real = { from: '0x6877BB79f680216BbdF01704939037F22193e771', diff --git a/src/validators/evm/lido/lido.validator.ts b/src/validators/evm/lido/lido.validator.ts index 0c8f2b9..8884970 100644 --- a/src/validators/evm/lido/lido.validator.ts +++ b/src/validators/evm/lido/lido.validator.ts @@ -5,7 +5,6 @@ import { ValidationContext, ValidationResult, } from '../../../types'; -import { isDefined } from '../../../utils/validation'; import { BaseEVMValidator, EVMTransaction } from '../base.validator'; const LIDO_CONTRACTS = { @@ -86,29 +85,23 @@ export class LidoValidator extends BaseEVMValidator { }); } - try { - const parsed = this.lidoInterface.parseTransaction({ - data: tx.data ?? '0x', - value: tx.value, - }); + const result = this.parseAndValidateCalldata(tx, this.lidoInterface); + if ('error' in result) return result.error; - if (!isDefined(parsed) || parsed.name !== 'submit') { - return this.blocked('Invalid method for staking', { - expected: 'submit', - actual: parsed?.name ?? 'unknown', - }); - } + const { parsed } = result; - const [referral] = parsed.args; - if (referral.toLowerCase() !== LIDO_REFERRAL.toLowerCase()) { - return this.blocked('Invalid referral address', { - expected: LIDO_REFERRAL, - actual: referral, - }); - } - } catch (error) { - return this.blocked('Invalid transaction data for staking', { - error: error instanceof Error ? error.message : String(error), + if (parsed.name !== 'submit') { + return this.blocked('Invalid method for staking', { + expected: 'submit', + actual: parsed.name, + }); + } + + const [referral] = parsed.args; + if (referral.toLowerCase() !== LIDO_REFERRAL.toLowerCase()) { + return this.blocked('Invalid referral address', { + expected: LIDO_REFERRAL, + actual: referral, }); } @@ -133,37 +126,31 @@ export class LidoValidator extends BaseEVMValidator { }); } - try { - const parsed = this.lidoInterface.parseTransaction({ - data: tx.data ?? '0x', - value: tx.value, - }); + const result = this.parseAndValidateCalldata(tx, this.lidoInterface); + if ('error' in result) return result.error; - if (!isDefined(parsed) || parsed.name !== 'requestWithdrawals') { - return this.blocked('Invalid method for unstaking', { - expected: 'requestWithdrawals', - actual: parsed?.name ?? 'unknown', - }); - } + const { parsed } = result; - const [amounts, owner] = parsed.args; + if (parsed.name !== 'requestWithdrawals') { + return this.blocked('Invalid method for unstaking', { + expected: 'requestWithdrawals', + actual: parsed.name, + }); + } - if (owner.toLowerCase() !== userAddress.toLowerCase()) { - return this.blocked('Withdrawal request owner is not user address', { - expected: userAddress, - actual: owner, - }); - } + const [amounts, owner] = parsed.args; - if (!Array.isArray(amounts) || amounts.length === 0) { - return this.blocked('Withdrawal amounts array is empty'); - } - } catch (error) { - return this.blocked('Invalid transaction data for unstaking', { - error: error instanceof Error ? error.message : String(error), + if (owner.toLowerCase() !== userAddress.toLowerCase()) { + return this.blocked('Withdrawal request owner is not user address', { + expected: userAddress, + actual: owner, }); } + if (!Array.isArray(amounts) || amounts.length === 0) { + return this.blocked('Withdrawal amounts array is empty'); + } + return this.safe(); } @@ -182,41 +169,32 @@ export class LidoValidator extends BaseEVMValidator { }); } - try { - const parsed = this.lidoInterface.parseTransaction({ - data: tx.data ?? '0x', - value: tx.value, - }); + const result = this.parseAndValidateCalldata(tx, this.lidoInterface); + if ('error' in result) return result.error; + + const { parsed } = result; - if (!isDefined(parsed)) { - return this.blocked('Invalid transaction data for claiming'); + if (parsed.name === 'claimWithdrawal') { + return this.safe(); + } else if (parsed.name === 'claimWithdrawals') { + const [requestIds, hints] = parsed.args; + + if (requestIds.length === 0) { + return this.blocked('Request IDs array is empty'); } - if (parsed.name === 'claimWithdrawal') { - return this.safe(); - } else if (parsed.name === 'claimWithdrawals') { - const [requestIds, hints] = parsed.args; - - if (requestIds.length === 0) { - return this.blocked('Request IDs array is empty'); - } - - if (requestIds.length !== hints.length) { - return this.blocked('Request IDs and hints arrays length mismatch', { - requestIdsLength: requestIds.length, - hintsLength: hints.length, - }); - } - return this.safe(); - } else { - return this.blocked('Invalid method for claiming', { - expected: 'claimWithdrawal or claimWithdrawals', - actual: parsed.name, + if (requestIds.length !== hints.length) { + return this.blocked('Request IDs and hints arrays length mismatch', { + requestIdsLength: requestIds.length, + hintsLength: hints.length, }); } - } catch (error) { - return this.blocked('Invalid transaction data for claiming', { - error: error instanceof Error ? error.message : String(error), + + return this.safe(); + } else { + return this.blocked('Invalid method for claiming', { + expected: 'claimWithdrawal or claimWithdrawals', + actual: parsed.name, }); } }