Ở đây, ta sẽ chú ý đến hàm call
. Trong Solidity, có 3 hàm dùng để chuyển Ether:
transfer
(2300 gas, throws error)send
(2300 gas, returns bool)call
(forward all gas or set gas, returns bool)Khác với hai hàm còn lại, call
sẽ không giới hạn số lượng gas sử dụng, ta sẽ sử dụng điều này để thực hiện Reentrancy attack.
Khi server contract gửi ether, contract nhận cần phải có 1 trong 2 hàm là receive()
và fallback()
. Do msg.data = ""
nên contract nhận sẽ dùng hàm receive
để nhận tiền. Nếu như trong hàm receive
ta rút tiền lần nữa, vậy sẽ xảy ra việc lặp vô tận và ta có thể rút tiền cho đến khi server contract cạn tiền.
Một điều nữa trong bài cho phép Reentrancy Attack hoạt động là do server contract cập nhật balances
sau khi gọi hàm call
, dẫn đến điều kiện balances[msg.server] >= _amount
luôn thỏa mãn, vì vậy ta có thể thực hiện hàm call
liên tục
Solve.sol
Bài học:
call
với Reentrancy GuardVới việc interface không được set view
hoặc pure
, ta có thể thay đổi state của data
solve.sol
isLastFloor
lần đầu, biến last
đang là false
, vậy khi gọi hàm lần nữa, last
sẽ trở thành true
và ta đã hoàn thànhBài học:
view
hoặc pure
cho interface hoặc abstract nếu không muốn bị thay đổiTrong bài này, ta chú ý rằng ta có thể xem dữ liệu ở slot bất kì trong storage của contract bằng hàm web3.eth.getStorageAt
trong web3js
. Nhưng việc xem ở slot nào mới là điều ta quan tâm.
Các tính chất cơ bản của storage được lưu tại đây
Mỗi slot trong storage của solidity có độ lớn là 32 bytes (tương ứng với 256 bits). Solidity sẽ tối ưu việc lưu data vào storage, tức là mỗi biến sẽ được cung cấp một vùng nhớ vừa đủ để lưu, tùy thuộc vào kiểu dữ liệu. Ở trong contract này, ta sẽ có
Do _key == bytes16(data[2])
, nên ta chỉ cần lấy dữ liệu tại slot 5, rồi convert sang bytes16 là được. Khi convert từ bytes32 sange bytes16, ta lấy 16 bytes đầu của bytes32 là được
Để rõ hơn về cách convert các bạn xem tại https://medium.com/coinmonks/solidity-variables-storage-type-conversions-and-accessing-private-variables-c59b4484c183
Bài học:
Ta cần vượt qua 3 điều kiện gateOne
, gateTwo
và gateThree
để có thể thay đổi địa chỉ của entrant
Với gateOne
, ta chỉ cần gọi server contract thông qua một contract khác là được. Chú ý rằng tx.origin
có giá trị là địa chỉ đã tạo ra contract (vì vậy giá trị này không thay đổi), còn msg.sender
có giá trị là địa chỉ người gửi hoặc địa chỉ đã tương tác với server contract (vì vậy có thể thay đổi)
Với gateTwo
, ta sẽ sử dụng hàm address(instanceAddress).call{gas: ?}(abi.encodeWithSignature("enter(bytes8)", key))
. Ở đây, ta sử dụng hàm call
để kiểm soát số lượng gas sử dụng sao cho gasleft() % 8191 = 0
. Ta sẽ gọi hàm enter()
ở server contract với key mà ta tìm được ở dưới
Với gateThree
, từ các điều kiện ta có thể suy luận như sau:
tx.Origin
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)
)Từ các điều kiện trên, ta có thể dễ dàng gen ra key thỏa mãn
solve.sol
Như bài trước, ta cần phải vượt qua 3 điều kiện để qua bài. Ta sẽ phân tích cách để vượt qua từng điều kiện
gateOne
, tương tự như bài trước, ta chỉ cần gọi contract server từ một contract trung gian là đượcgateTwo
, ta muốn giá trị extcodesize(caller()) = 0
. Đây là cách để kiểm tra address gọi đến có phải là contract address hay không, nếu là contract address thì sẽ trả về giá trị lớn hơn 0. Tuy nhiên, nếu đọc kĩ yellow-paper ta sẽ thấy chú ý nhỏ:enter()
trong constructor của contract, extcodesize
sẽ bằng 0gateThree
, đây là phép xor cơ bản: A xor B = C
thì B = A xor C
solve.sol
Bài này yêu cầu ta tiêu hết token của chính mình. Tuy nhiên, hàm transfer
của ERC20 đã bị ghi đè bởi hàm transfer
của server token. Để có thể gọi hàm transfer
, ta cần phải vượt qua điều kiện lockTokens()
, tức là cần đợi 10 năm :)) . Việc đợi này không khả thi nên ta sẽ tìm cách chuyển khác.
Chú ý rằng ERC20 có 2 hàm dùng để chuyển token là transfer
và transferFrom
. Cả hai hàm cơ bản đều có chức năng như nhau, đó là chuyển token từ account này sang account khác. Tuy nhiên, hàm transferFrom
không bị điều kiện lockTokens
ràng buộc nên ta sẽ sử dụng hàm này để chuyển token.
Bài học:
Trong bài này, ta cần tìm cách để trở thành owner của server transaction
Ta chú ý đến hàm delegatecall
: Đây là một hàm low-level, cho phép contract A gọi hàm từ contract B trong khi storage và context của A vẫn giữ nguyên. Khi sử dụng delegatecall
, một trong những điều ta cần chú ý là layout của các biến. Khi layout của các biến trong A và B khác nhau, việc sử dụng delegatecall
sẽ không khả thi do ta truy cập sai dữ liệu cần sử dụng.
Quay trở lại challenge, ta thấy hai contract là LibraryContract
và Preservation
có cách khai báo biến khác nhau. Chính vì vậy, khi gọi hàm setTime
qua delegatecall
, biến storedTime
trong contract thứ hai sẽ chính là biến timeZone1Library
(Do đều nằm ở slot 0). Từ đó, khi gọi hàm setSecondTime
, ta sẽ có thể chỉnh sửa giá trị của timeZone1Library
.
Do ta có thể thay đổi giá trị của timeZone1Library
, ta sẽ muốn thay đổi nó trở thành địa chỉ của một contract mà ta có thể điều khiển được. Contract Attack của ta có thể viết như sau:
Contract Attack của ta phải có cùng layout các biến tương đương như contract Preservation
, có cùng tên hàm và kiểu dữ liệu (để có thể gọi được qua hàm delegatecall
)
Vậy là đã setup xong, ta tiến hành attack như sau
Preservation
và deploy contract Solve
Preservation
, ta gọi hàm setFirstTime
với tham số bất kì, để hai biến storedTime
và timeZone1Library
có cùng địa chỉsetSecondTime
với tham số là uint256(_address)
, trong đó _address
là địa chỉ của contract Solve
. Như vậy, địa chỉ timeZone1Library
sẽ được thay thế bởi địa chỉ của contract Solve
setFirstTime
với tham số bất kì, khi đó delegatecall
sẽ gọi hàm setTime
trong contract Solve
, và giúp ta trở thành owner!Bài học:
delegatecall
, cần sắp xếp layout của các biến trong các contract được gọi thật cẩn thậnTrong bài này, ta cần xóa hết ether trong 1 contract mà server contract đã chuyển đến
Để tìm thấy địa chỉ mà server contract đã chuyển tiền đến, ta sử dụng etherscan. Khi tạo instance, ta vào etherscan để xem server contract đã chuyển tiền cho ai
https://sepolia.etherscan.io/tx/0x24165f30905bf766e104be191c8d020a925d9c19558c96ae3745746c68b6938f
Ta thấy rằng địa chỉ 0x7C4f76d5137C12446226CB22B21622A549A7eD61
đã được nhận 0.001 ether, vậy ta sẽ tìm cách hủy hết tiền của transaction này
Ta sẽ sử dụng hàm selfdestruct
trong function destroy
của contract SimpleToken
. selfdestruct(_to)
cho phép ta xóa một contract và chuyển toàn bộ ether trong contract đó về địa chỉ _to
solve.sol
Vậy chiến thuật của ta sẽ như sau
SimpleToken
từ địa chỉ có 0.001 ether, ở đây là 0x7C4f76d5137C12446226CB22B21622A549A7eD61
destroy
với một địa chỉ bất kì, ở đây mình sẽ sử dụng address của bản thân, như vậy là ta đã hủy hết tiền trong địa chỉ ở trên và chuyển tiền trong địa chỉ đó về ví taTrong bài này, ta cần chiếm quyền owner của server contract.
Ở đây, ta chú ý codex
là dynamic array, tức là các giá trị trong codex
được lưu trong storage với cách tính index khác với fixed array. Cụ thể hơn, codex[0]
sẽ được lưu ở slot keccak256(abi.encode(1))
, codex[1]
được lưu ở slot keccak256(abi.encode(2))
,…
Khi đã biết cách tính vị trí trong storage của các phần tử codex
, ta có thể sử dụng hàm revise()
để thay đổi data của codex[i]
. Ta sẽ lợi dùng điều này để chiếm quyền owner.
Khi quan sát hàm retract()
, ta thấy mình có thể gọi hàm này khi nào cũng được. Vậy sẽ ra sao khi ta gọi hàm này khi mảng codex
của ta không có phần tử nào ? Khi đó sẽ xảy ra tràn số nguyên, và độ dài của mảng codex
sẽ trở thành maxint(uint256)
, tức là ta có thể truy cập toàn bộ slot của storage.
Khi đã có thể truy cập toàn bộ slot của storage, ta sẽ tìm i
sao cho codex[i]
lưu ở slot 0, tức là slot chứa owner address. Ta chỉnh sửa phần tử codex[i]
bằng hàm revise
với address là address của ta. Done!
Bài học:
Trong bài này, ta cần làm cho owner không thể gọi được hàm withdraw()
.
Ta sẽ tìm cách để tăng lượng gas sử dụng khi owner dùng hàm withdraw()
. Nếu chú ý, ta sẽ thấy server contract sử dụng hàm call
để gửi tiền đến contract partner
. Tuy nhiên, hàm call là một hàm khá nguy hiểm. Khi sử dụng hàm call
để chuyển tiền, contract partner
sẽ nhận tiền ở hàm receive()
(do server contract có hàm receive()
), vậy ta sẽ làm cho hàm receive()
chứa đoạn code có lỗi, dẫn đến lượng gas cần sử dụng trở nên cực kì lớn, và do đó server contract không thể thực hiện hàm withdraw()
được.
solve.sol
Bài học:
call
: có thể dẫn đến reentrancy attackBài này yêu cầu ta làm cho biến isSold
trả về true
với giá thấp hơn. Nếu để ý, ta sẽ thấy bài này tương tự như bài Elevator, tuy nhiên bây giờ hàm price()
đã được định nghĩa là view
.
Ý tưởng tương tự như bài Elevator, đó là ta sẽ override hàm price
sao cho ta có thể vượt qua điều kiện if (_buyer.price() >= price && !isSold)
. Ta chú ý rằng function được định nghĩa là view
vẫn cho phép ta đọc giá trị. Do đó, ta sẽ override hàm price()
trả về giá trị đủ lớn để vượt qua điều kiện, và sau khi gọi lại lần nữa, giá trị trả về của price()
sẽ nhỏ hơn value
.
solve.sol
Trong bài này, ta cần rút hết tiền của server contract dựa trên việc thao túng AMM.
Ta sẽ chú ý đến hai hàm swap(address from, address to, uint256 amount)
và getSwapPrice(from, to, amount)
:
Hàm này cho phép ta chuyển một lượng amount
token from
qua token to
trong ví ta. Lưu ý rằng với mỗi lần swap, liquidity sẽ bị thay đổi do cách tính toán trong hàm getSwapPrice
và số lượng token của server contract bị thay đổi. Ta sẽ lợi dụng điều này để rút hết token của server contract.
Ta sẽ rút tiền như bảng sau:
Lưu ý rằng ta phải cho phép instance address sử dụng token thì mới có thể gọi hàm swap()
được. Ta sẽ gọi hàm approve()
với giá trị value
vừa đủ.
Challenge yêu cầu ta rút hết token 1 và token 2 từ server contract.
Trong bài này, ta chú ý rằng hàm swap()
không kiểm tra token sử dụng có phải là token của contract hay không, nên ta suy nghĩ đến việc tạo ra một token mới. Khi đó, ta có thể điều khiển được liquidity của dex.
Trong bài này, ta cần rút hết tiền của contract và trở thành admin.
Như challenge description đã gợi ý, mình tìm hiểu storage layout của proxy ra sao và tìm được link này. Mình để ý đến đoạn này: "A proxy contract and its delegate/logic contracts or facets (all terms for the same thing) share the same storage layout!". Như vậy, ta sẽ hiểu là proxy contract và delegate contract chia sẻ chung storage layout. Do đó, 2 biến pendingAdmin
của PuzzleProxy
và owner
của PuzzleWallet
nằm cùng một slot, tương tự với admin
và maxBalance
.
Chính vì vậy, ta có thể trở thành owner của contract PuzzleWallet
bằng cách gọi hàm proposeNewAdmin
của contract PuzzleProxy
với input là address của contract attack để có thể cấp quyền whitelist
.
Khi đã trở thành owner, ta sẽ tìm cách trở thành admin trong contract PuzzleProxy
. Như vậy, ta cần chỉnh sửa giá trị của maxBalance
. Để ý rằng maxBalance
có thể thay đổi thông qua hàm setMaxBalance
, nên ta sẽ tìm cách gọi hàm này.
Ta cần vượt qua điều kiện require(address(this).balance == 0, "Contract balance is not 0");
, tức là rút hết tiền của server contract. Khi check trên console của Ethernaut bằng web3js, ta có thể thấy được server contract đang có 0.001 eth.
Ta sẽ chú ý đến hàm execute
. Đây là hàm giúp ta có thể rút tiền của server contract nếu ta vượt qua điều kiện require(balances[msg.sender] >= value, "Insufficient balance");
. Để có thể thay đổi giá trị của balances[msg.sender]
, ta chú ý đến hàm deposit()
. Nếu chỉ gọi hàm này, cả server contract và balances[msg.sender]
đều sẽ nhận được ether, và vì thế ta sẽ không bao giờ thực hiện được hàm execute()
. Chính vì vậy, ta sẽ hướng đến hàm multicall
.
Trong hàm multicall
, ta có thể call nhiều hàm cùng một lúc, và trong hàm cũng đã check việc gọi hàm deposit
, khiến ta không thể gọi hai lần hàm deposit được. Nhưng hàm này không check việc gọi lại chính nó, và ta sẽ sử dụng lỗi này để exploit.
Do hàm multicall
chỉ check xem hàm deposit
có được gọi nhiều hơn 1 lần hay không với mỗi lần chạy, ta sẽ gọi hàm multicall
trong chính nó, đồng thời khi gọi thì ta sẽ gọi tiếp hàm deposit
. Với cùng msg.sender
, ta có thể tăng giá trị của balances[msg.sender]
lên 0.002 eth, trong khi server contract chỉ tăng lên thêm 0.001 eth. Như vậy, balances[msg.sender]
đã bằng với số eth có trong contract, và ta có thể rút hết tiền của contract PuzzleWallet
.
Như đã đề cập việc chia sẻ storage layout ở trên, do admin
và maxBalance
cùng 1 slot nên khi đã rút hết tiền của PuzzleWallet
, ta chỉ việc gọi hàm setMaxBalance
với giá trị là address của ta là xong.
solve.sol
Bài học:
delegatecall
.msg.value
will remain the same, so the developer must manually keep track of the actual remaining amount on each iteration. This can also lead to issues when using a multi-call pattern, as performing multiple delegatecall
s to a function that looks safe on its own could lead to unwanted transfers of ETH, as delegatecall
s keep the original msg.value
sent to the contract."Trong bài này, ta cần destruct contract Engine
bằng cách sử dụng selfdestruct
.
Tuy nhiên, ta không thấy hàm selfdestruct
trong contract Engine
, nên ta sẽ đi tìm hàm có vẻ nguy hiểm, và ta chú ý đến hàm upgradeToAndCall
.
Để gọi được hàm upgradeToAndCall
, ta cần phải trở thành upgrader
bằng cách gọi hàm initialize()
. Nhưng để gọi hàm này được, ta cần phải vượt qua modifier initializer
.
Nếu như ta chỉ gọi hàm initialize()
bình thường, ta sẽ bị lỗi do để truy cập được contract, ta phải đi qua proxy UUPS, và nó đã được initialize. Vì vậy, ta có thể nghĩ đến việc interact với contract Engine
với address trong Proxy Storage, đó chính là address của Engine
khi chưa đi qua UUPS. Bằng cách này, ta có thể gọi hàm initialize()
và lấy quyền upgrade.
Khi đã có quyền upgrade, ta có thể sử dụng hàm upgradeToAndCall
với address là address của một contract có chứa function selfdestruct
. Bằng cách này, khi được gọi thông qua delegatecall
, contract sẽ hiểu function delegatecall
là của Engine
contract và tự destruct nó.
Lí thuyết là vậy, tuy nhiên từ bản Dencun fork, selfdestruct
chỉ có thể xóa contract khi đã ở trong contract từ trước, chứ không thể xóa thông qua hàm delegatecall
, do đó ta không thể destruct contract Engine
như yêu cầu đề bài được. Xem như mình biết thêm một cách attack :)
Trong bài này, ta cần rút hết coin của contract GoodSamaritan
.
Ta chú ý đến hàm requestDonation
. Hàm này cho phép ta nhận 10 coin từ contract GoodSamaritan
nếu như không có lỗi gì, hoặc nhận toàn bộ coin nếu như khi gặp lỗi NotEnoughBalance()
. Ta sẽ đọc kĩ hơn cách mà method .donate10()
bị lỗi.
Method .donate10()
trong contract Wallet
chuyển coin bằng method .transfer()
của contract Coin
. Đọc kĩ hàm .transfer()
, ta thấy được nếu msg.sender
là contract thì nó sẽ gọi hàm notify()
trong interface Inotifyable
.
Nhận thấy interface Inotifyable
không có restrict là pure
hoặc view
, vì thế ta có thể chỉnh sửa hàm notify()
trong này. Ta sẽ muốn hàm này trả về lỗi NotEnoughBalance()
khi method .donate10()
được gọi và hoạt động bình thường với method transferRemainder()
. Do lỗi NotEnoughBalance()
là custom error nên ta có thể cài đặt được trong contract attack của ta.
Tương tự như hai bài Gatekeeper trước, ta cần vượt qua các modifier gateOne
, gateTwo
, gateThree
.
Ta sẽ tìm cách vượt qua gateTwo
trước. Modifier gateTwo
yêu cầu allowEntrance == true
, và điều này đạt được khi ta gọi hàm getAllowance()
với password đúng. Để tìm được password, ta cần phải gọi hàm createTrick()
để contract SimpleTrick
gen password. Sau đó, ta có thể đọc giá trị của password
trong contract SimpleTrick
thông qua lệnh web3.eth.getStorageAt()
với slot 2. Vậy là ta đã có password và vượt qua gateTwo
.
Đối với gateThree
, ta chia làm hai phần tương ứng với 2 điều kiện ta cần vượt qua là address(this).balance > 0.001 ether
và payable(owner).send(0.001 ether) == false)
. Với điều kiện đầu, do contract có hàm receive()
nên ta chỉ cần chuyển tiền vào với hàm sendTransaction
của web3js là được.
Đối với gateOne
và điều kiện sau của gateThree
, ta sẽ tạo contract attack với những điều kiện sau:
owner
chính là địa chỉ của attack contracttx.origin
là địa chỉ của tafalse
Như thế, contract attack của ta sẽ như sau:
Giờ thì deploy attack contract và interact nữa là xong.
Trong bài này, ta cần tìm cách để biến switchOn
trở thành true
.
Ta cần phải gọi được hàm turnSwitchOn()
để có thể chuyển trạng thái của switchOn
trở thành true
, tuy nhiên, modifier onlyThis
yêu cầu chỉ có contract mới có thể vượt qua nên ta sẽ nghĩ cách gọi hàm turnSwitchOn()
ở chỗ khác.
Ta chú ý đến hàm flipSwitch()
, nếu gọi được hàm turnSwitchOn()
bằng method .call
ở trong này thì có thể vượt qua modifier onlyThis
. Để gọi được hàm flipSwitch()
, ta cần xem qua modifier onlyOff
.
Với modifier onlyOff
, ta cần vượt qua điều kiện selector[0] == offSelector
. Ở đây, selector
được tạo bằng cách copy 4 bytes từ byte thứ 68 (vị trí bắt đầu của _data
), do đó, ta sẽ tìm cách làm cho 4 bytes từ byte thứ 68 của _data
bằng với bytes4(keccak256("turnSwitchOff()"))
, trong khi ta vẫn gọi được hàm turnSwitchOn()
.
Trong đề bài đã hint về việc encode trong CALLDATA
, và có thể đọc ở Solidity doc tại đây. Ta chú ý rằng kiểu dữ liệu bytes
là dynamic types, do đó ta cần sử dụng offset ở dạng bytes để biết được data bắt đầu từ đâu. Do ta có thể điều kiển việc đọc data của solidity qua offset, nên ta sẽ nghĩ đến việc chỉnh sửa offset của nó.
Với idea như vậy, _data
của ta sẽ là 0x30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000
, trong đó:
30c13ade
: Method ID của hàm flipSwitch()
, tức là bytes4(keccak256("flipSwitch()"))
. Ta sẽ gọi hàm bằng cách này nếu gọi qua .call
0000000000000000000000000000000000000000000000000000000000000060
: Giá trị của offset. Ở đây, ta sẽ muốn đọc giá trị bắt đầu từ byte 96 (không tính Method ID).0000000000000000000000000000000000000000000000000000000000000000
: Các byte thêm vào ngẫu nhiên. Do selector
lấy 4 bytes từ byte thứ 68 (tính cả Method ID) trở đi, ta cần thêm 32 bytes để có thể điều khiển được selector
sẽ lấy gì.20606e1500000000000000000000000000000000000000000000000000000000
: MethodID của hàm turnSwitchOff()
. Nếu đặt ở đây, selector
sẽ nhận 4 bytes 20606e15
và do đó, ta có thể vượt qua điều kiện selector[0] == offSelector
0000000000000000000000000000000000000000000000000000000000000004
: Độ dài của _data
76227e1200000000000000000000000000000000000000000000000000000000
: MethodID của turnSwitchOn()
. Như vậy, method .call
sẽ gọi hàm turnSwitchOn
và chuyển switchOn = true
Bài học: "Assuming positions in CALLDATA
with dynamic types can be erroneous, especially when using hard-coded CALLDATA
positions."
Trong bài này, ta cần trở thành commander
bằng cách gọi thành công hàm claimLeadership()
.
Để gọi được hàm claimLeadership()
, ta cần phải làm cho biến treasury
lớn hơn 255. Hàm duy nhất có thể chỉnh sửa biến treasury
chính là registerTreasury()
, tuy nhiên hàm này chỉ cho phép input nhỏ hơn 256 (uint8
). Tuy nhiên, điều mà ta chú ý ở đây chính là hàm calldataload(4)
.
Hàm calldataload(4)
sẽ load 32 bytes từ byte thứ 4 trong data, nhưng ta cần làm rõ 'data' ở đây là gì, và mình tìm được câu trả lời ở đây. Như vậy, ta có thể custom msg.data
với việc gọi hàm registerTreasury(uint8)
với input lớn hơn 255, bằng cách này, ta có thể gọi hàm claimLeadership()
và hoàn thành challenge!
Bài này có thể giải trên console với web3js bằng command:
với 211c85ab
là MethodID của registerTreasury(uint8)
và 0000000000000000000000000000000000000000000000000000000000000100
là input của function.
Với bài này, ta cần làm cho biến closed
trả về true là được. Để làm như vậy thì chỉ cần gọi hàm wannaLuck
với seed = 1
cho đến khi random % 100 == 0
.
setup.sol
vault.sol
Trong bài này, ta cần làm cho số lượng token của vault > 1000 * 1e18
Chú ý rằng vault không check ta sử dụng token nào, vì vậy ta có thể generate một token ngẫu nhiên nào đó và mint cho bản thân một số lượng lớn, sau đó chỉ cần deposit vào vault là được
setup.sol
StakingManager.sol
LpToken.sol
Trong bài này, ta cần làm cho lượng token trong contract Setup > 10 * 1e18
, với việc bắt đầu từ 1e18
token (nhận được thông qua hàm withdraw()
)
Đọc qua contract StakingManager
, ta thấy rằng sau một khoảng thời gian, số tiền mà ta gửi vào sẽ tăng lên do rewardPerToken
. Tuy nhiên, nếu chỉ gửi tiền và đợi thì số lượng token không thể tăng lên tới 10 * 1e18
token được (server có timeout 30'). Do đó, ta cần tìm cách khác.
Khi đọc cách tính hàm rewardPerToken
, ta thấy việc tính toán dựa vào block.timestamp
và totalSupply
của LpToken
. Do việc kiểm soát block.timestamp
là không thể nên ta sẽ thử tìm cách điều khiển totalSupply
.
Trong contract LpToken.sol
, ta chú ý có 2 hàm là burnFrom
và mint
. Trong khi hàm mint
yêu cầu chỉ có minter
mới thực hiện được thì hàm burnFrom
lại không cần điều kiện gì. Đây chính là vulnerable mà ta có thể khai thác.
Nếu đọc kĩ Setup.sol
, ta thấy rằng contract đã gọi hàm stake
với giá trị 100000 * 1e18
, do đó, lượng LpToken
của contract là 100000 * 1e18
, từ đó, ta có thể gọi hàm burnFrom
với address của contract setup
và số lượng LpToken
thật lớn, từ đó totalSupply
của LpToken
sẽ giảm mạnh, dẫn đến việc giá trị của rewardPerToken
tăng lên rất cao.
Chiến thuật:
Setup
qua hàm withdraw
burnFrom
với address của contract Setup
và giá trị value
thật lớn, nhưng không làm cho giá trị của totalSupply
trở về 0rewardPerToken
trong hàm update()
thì giá trị của nó sẽ tăng lên rất nhiều. Từ đó ta chỉ cần gọi hàm stakeAll
để lấy hết tiền là được.