Ethernaut 15 Naught Coin
Ethernaut 15 Naught CoinEthernaut 15 Naught CoinEthernaut 15 Naught Coin 문제 풀이Ethernaut 15 Naught Coin writeup
Contents
문제
지문
NaughtCoin is an ERC20 token and you're already holding all of them. The catch is that you'll only be able to transfer them after a 10 year lockout period. Can you figure out how to get them out to another address so that you can transfer them freely? Complete this level by getting your token balance to 0. Things that might help
- The ERC20 Spec
- The OpenZeppelin codebase
코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
contract NaughtCoin is ERC20 {
// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}배경지식
ERC20은 transfer, approve, transferFrom, balanceOf, allowance 같은 공통 함수를 정해둔 토큰 표준이다.
NaughtCoin은 전송 로직을 직접 구현하지 않고 OpenZeppelin의 ERC20을 상속한다. 따라서 코드에 보이는 transfer와 함께 상속받은 ERC20 표준 함수들도 봐야 한다.
ERC20에서 토큰을 옮기는 대표적인 방법은 두 가지다.
transfer(to, amount)는 msg.sender의 토큰을 바로 to에게 보낸다.
function transfer(address to, uint256 value) external returns (bool);
transferFrom(from, to, amount)는 from의 토큰을 to에게 보낸다. 대신 아무나 남의 토큰을 움직일 수 있으면 안 되므로, 먼저 from이 msg.sender에게 allowance를 열어줘야 한다.
function transferFrom(address from, address to, uint256 value) external returns (bool);
approve(spender, amount)는 spender가 내 토큰을 amount만큼 가져갈 수 있도록 허용하는 함수다. 이 허용량은 allowance(owner, spender)로 조회할 수 있다.
흐름은 다음과 같다.
player가approve(spender, amount)를 호출한다.spender가transferFrom(player, receiver, amount)를 호출한다.- ERC20은
allowance(player, spender)를 차감하고player의 잔액을receiver로 옮긴다.
이 문제에서는 spender를 그냥 player 자기 자신으로 둬도 된다. approve(player, amount)로 자신에게 allowance를 열고, 같은 주소에서 transferFrom(player, receiver, amount)를 호출하면 msg.sender는 player이므로 allowance 조건을 만족한다.
문제 코드 분석
초기 토큰은 생성자에서 지급된다.
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}컨트랙트가 배포되면 player에게 전체 발행량이 민팅된다. 목표는 이 player의 토큰 잔액을 0으로 만드는 것이다.
INITIAL_SUPPLY는 1000000 * 10^18이다. OpenZeppelin ERC20의 기본 decimals()가 18이기 때문에 실제 최소 단위 기준으로는 1000000000000000000000000이 된다.
잠금은 여기서 걸린다.
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}lockTokens는 msg.sender == player일 때 10년이 지나야 통과하게 만든다. 그런데 이 modifier가 붙은 함수는 transfer뿐이다.
player가 transfer를 직접 호출하면 막힌다. 하지만 ERC20에는 transferFrom도 있고, NaughtCoin은 transferFrom을 override하지 않았다. 상속받은 OpenZeppelin의 transferFrom은 그대로 열려 있다.
제한은 한쪽에만 걸려 있다.
contract NaughtCoin is ERC20 {
...
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
}보안상 의도는 player의 토큰 이동을 10년 동안 막는 것이다. 그렇다면 transfer뿐 아니라 transferFrom으로 player의 잔액이 빠져나가는 경우도 제한해야 한다.
하지만 현재 코드는 transfer만 막고, approve와 transferFrom은 막지 않는다. 따라서 player가 먼저 allowance를 열어준 뒤 transferFrom으로 자신의 잔액을 다른 주소로 옮기면 문제 조건을 만족한다.
풀이
lockTokens에 막히는 transfer 대신, NaughtCoin이 따로 막지 않은 approve와 transferFrom을 쓰면 된다.
전체 토큰 수량만큼 player에게 allowance를 열고, transferFrom(player, recipient, amount)를 호출하면 된다. 여기서 recipient는 내 다른 지갑 주소나 임의의 수신 주소면 된다. 조건은 player의 balanceOf(player)가 0이 되는 것이다.
익스플로잇
await contract.approve(player, "1000000000000000000000000")
await contract.transferFrom(player, "0x828AA4DE1439A14e7440F4200358B6a51D06cFE3", "1000000000000000000000000")
Challenge
Prompt
NaughtCoin is an ERC20 token and you're already holding all of them. The catch is that you'll only be able to transfer them after a 10 year lockout period. Can you figure out how to get them out to another address so that you can transfer them freely? Complete this level by getting your token balance to 0. Things that might help
- The ERC20 Spec
- The OpenZeppelin codebase
Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
contract NaughtCoin is ERC20 {
// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}Background
ERC20 is a token standard that defines common functions such as transfer, approve, transferFrom, balanceOf, and allowance.
NaughtCoin inherits OpenZeppelin's ERC20 instead of implementing the transfer logic itself, so the inherited ERC20 standard functions matter as much as the transfer visible in the code.
There are two main ways to move tokens in ERC20.
transfer(to, amount) sends msg.sender's tokens directly to to.
function transfer(address to, uint256 value) external returns (bool);
transferFrom(from, to, amount) sends from's tokens to to. Since not just anyone should be able to move someone else's tokens, from must first open an allowance to msg.sender.
function transferFrom(address from, address to, uint256 value) external returns (bool);
approve(spender, amount) allows spender to take up to amount of my tokens. This allowance can be queried with allowance(owner, spender).
The flow looks like this:
playercallsapprove(spender, amount).spendercallstransferFrom(player, receiver, amount).- ERC20 deducts
allowance(player, spender)and movesplayer's balance toreceiver.
In this challenge, spender can simply be player itself. By opening an allowance to yourself with approve(player, amount) and calling transferFrom(player, receiver, amount) from the same address, msg.sender is player, so the allowance condition is satisfied.
Challenge code analysis
The initial tokens are handed out in the constructor.
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}When the contract is deployed, the entire supply is minted to player. The goal is to make this player's token balance 0.
INITIAL_SUPPLY is 1000000 * 10^18. Because OpenZeppelin ERC20's default decimals() is 18, in terms of the smallest unit this comes to 1000000000000000000000000.
The lock is applied here.
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}lockTokens requires 10 years to pass to proceed when msg.sender == player. But the only function with this modifier is transfer.
If player calls transfer directly, it is blocked. But ERC20 also has transferFrom, and NaughtCoin did not override transferFrom. The inherited OpenZeppelin transferFrom is left open.
The restriction only covers one side.
contract NaughtCoin is ERC20 {
...
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
}The security intent is to block player's token movement for 10 years. That would require restricting transferFrom as well as transfer.
The current code only blocks transfer and leaves approve and transferFrom open. So if player first opens an allowance and then moves its own balance to another address with transferFrom, the challenge condition is met.
Solution
Skip transfer, which lockTokens blocks, and use approve and transferFrom, which NaughtCoin leaves alone.
Open an allowance to player for the full token amount and call transferFrom(player, recipient, amount). Here recipient can be another of my wallet addresses or any receiving address. The condition is that player's balanceOf(player) becomes 0.
Exploit
await contract.approve(player, "1000000000000000000000000")
await contract.transferFrom(player, "0x828AA4DE1439A14e7440F4200358B6a51D06cFE3", "1000000000000000000000000")