Secureum은 컨트랙트 오딧 관련 부트캠프다. 해당 substack에 들어가면 잘 정리된 여러 글을 볼 수 있고 유튜브에도 설명 영상이 있어서 컨트랙트 오딧을 처음 배우는 나 같은 사람들에게는 매우 좋은 자료다. 디스코드 또한 잘 되어있고 활발하기 때문에 관심있으면 들어가보는 것을 추천한다. 이번 글은 Secureum의 글을 읽고 몇 가지 사례들을 정리해봤다.
ERC777은 ERC20의 확장팩이라고 생각하면 된다. ERC20과의 가장 큰 차이점은 receive hook
이다. hook을 이용하면 토큰을 받을 때 특정 반응을 할 수 있다. 예를 들어 토큰을 거절한다든지, 토큰을 받자마자 다른 곳으로 전송한다든지 다양한 기능을 수행할 수 있다.
하지만 이를 이용해 reentrancy 공격을 당하는 사건이 발생했다. lendF.Me 랜딩플랫폼에서 imBTC를 담보로 대출을 받을 수 있게 했었다. 해커는 처음엔 정상적으로 많은 양의 imBTC를 전송했다. 그 다음 그 보다 적은 양의 imBTC를 전송하면서 동시에 이전에 전송했던 대량의 imBTC를 인출했다. Lendf.Me에서는 해당 케이스를 사전에 고려하지 않았었기 때문에 인출된 양은 업데이트 되지 않았고 오히려 담보로 잡힌 imBTC가 증가한 것으로 처리되었다. 이런 과정을 수차례 반복 후 실체 없는 담보를 대상으로 프로토콜 대부분의 자금을 대출해갔다.
이러한 사건을 사전에 방지하기 위해서 reentrancy guard는 필수다.
곱하기와 나누기를 같이 쓸 일이 있으면 곱하기를 먼저하는 것이 좋다. 솔리디티에서 나누기를 먼저 하면 숫자가 잘려서 값이 달라질 수 있다.
다소 극단적인 예시일 수 있지만 approve()
를 통해 위임한 토큰의 수량을 업데이트 할 때 악의적으로 수량이 바뀔 수 있다. 위 예시처럼 Alice가 Eve에게 이전에 위임했던 수량을 100개에서 10개로 줄이려고 한다고 해보자. 노드를 돌리고 있는 Eve가 이를 알아차리고 transferFrom()
을 통해 더 많은 가스비를 지불해서 먼저 토큰을 자신에게 전송 시킬 수 있다. 이후 Eve는 위임 수량을 0개로 업데이트하고(가스비를 더 많이 지불한 approve 트랜잭션으로) 그제서야 Alice의 트랜잭션이 통과되서 최종 수량은 10개로 설정된다. 이후 Eve는 또 한 번 transferFrom()
을 통해 10개를 자신에게 전송한다. 최종적으로 Eve는 110개의 토큰을 탈취할 수 있다.(악마도 한 수 접고 갈 진짜 악질..)
이를 방지하기 위해 OpenZeppelin의 SafeERC20 라이브러리를 통해 safeIncreaseAllowance()
및 safeDecreaseAllowance()
을 사용하자.
어떤 컨트랙트냐에 따라 다르겠지만 위 예시의 크라우드 세일 같은 경우, 컨트랙트에 모인 금액이 정확히 100이더가 아니라면(조금이라도 넘을 경우) 크라우드 세일은 끝나지 않는다. 따라서 정확한 양을 비교하기 보다는(==
) 이상 혹은 이하(>= or <=
)로 비교하는 것이 적절하다.
mapping이 있는 struct
는 remove
로 삭제되지 않는다. 따라서 예상과 다른 결과를 얻을 수 있으니 조심.
반복문 안에서 직접적으로 변수값을 계속 바꾸는 것은 가스비가 많이 든다. 가스가 충분하지 않다면 out-of-gas
가 발생하면서 컨트랙트 내 다른 기능들이 제대로 동작하지 않을 수 있다. 따라서 두 번째 함수처럼 전역변수를 함수 내에 있는 지역변수에 할당해서 따로 반복문을 돌린 다음 전역변수에 값을 할당하는 것이 바람직하다.
contract Refunder {
address[] private refundAddresses;
mapping (address => uint) public refunds;
constructor() {
refundAddresses.push(0x79B483371E87d664cd39491b5F06250165e4b184);
refundAddresses.push(0x79B483371E87d664cd39491b5F06250165e4b185);
}
// bad
function refundAll() public {
for(uint x; x < refundAddresses.length; x++) { // arbitrary length iteration based on how many addresses participated
require(refundAddresses[x].send(refunds[refundAddresses[x]])); // doubly bad, now a single failure on send will hold up all funds
}
}
}
마찬가지로 반복문 안에서 계속해서 call
을 호출하거나 이더를 보내는 것은 좋지 않다. 계속 이더를 보내다가 어느 한 주소가 revert를 내거나 중간에 가스를 모두 소진하면 나머지 주소는 이더를 받지 못하기 때문이다. 또한 이는 DoS
공격으로 이어질 수 있다. Dos는 Denial of Service
의 약자로 해당 서비스를 더 이상 이용하지 못 하게 만드는 공격이다. 간단한 예시로는 KingOfEther가 있다. 이를 방지하기 위해 각 주소를 mapping으로 관리하고, 별도로 출금할 수 있도록 withdraw
함수를 따로 만들어두는 것이 좋다.