일부 스마트컨트랙트 취약점 정리

iwin1203·2022년 11월 20일
0

블록체인

목록 보기
11/11

스마트컨트랙트 audit 툴을 최근 보고있다. Securify2, Smartcheck, Conkas라는 툴의 탐지 항목 중 정확히 이해하지 못한 것들을 정리해봤다.

개인적으로 취약점마다 뉘앙스나 층위가 좀 다르다고 느꼈다. 블록체인 구조 상의 취약점, 솔리디티 문법 상의 취약점이 혼재되어있고, 또 그것도 악의적인 행위 없이도 발생할 수 있는 취약점과, 악의적인 외부 행위가 있어야 발생하는 취약점으로 나뉘기 때문이다.




TOD; Transaction Order Dependency

트랜잭션 순서에 따라 결과값이 달라질 수 있는 경우, 공격자가 자신의 트랜잭션 순서를 조정해 이득을 취할 수 있다. 블록에 싣는 과정에서의 Race Condition이라 볼 수 있다. 앞질러 트랜잭션을 성공시킨다는 점에서 frontrunning이라고도 한다.

시나리오>

  • Alice가 Eve에게 자신의 토큰을 n개 만큼 사용할 수 있도록 approve했다.
  • 그런데 Alice가 갑자기 마음이 바뀌어서 m개만 사용할 수 있도록 approve 내용을 변경하고자 한다.
  • Alice는 인자 값을 변경하여 approve를 호출했다.

  • 한편 Eve는 이더리움 노드를 운영하고 있어 트랜잭션 내용을 살펴볼 수 있다.
  • Eve는 pool에서 Alice가 기존의 n개가 아닌, m개로 approve 함수를 호출한 것을 보았다.

  • Eve는 후딱 Alice의 토큰 n개를 빼오도록 transferFrom를 높은 가스비와 함께 호출한다.
  • 채굴자는 일반적으로 mempool 내 트랜잭션을 가스비를 기준으로 정렬한다. 그렇기에 n개를 빼오는 트랜잭션이 먼저 실행된다.

  • 이후, m개로 approve가 된다. 그렇지만, 이미 n개는 빠져나간 상황이다.
  • m개로 새로 approve한 만큼 Eve는 다시 transferFrom으로 m개를 빼온다.
  • 결과적으로 Eve는 n개도 아니고, max(m,n)개도 아니고, 심지어 m+n개를 갖게 된 것!

<해결방안>
commit reveal hash scheme을 사용한다.

erc20 approve specific하게는, 현재 allowance를 직접 체크한 뒤 approve 한도를 inc/decrease하는 것이다.
이는 Openzeppelin SafeERC20.sol에 이미 safeIncreaseAllowance / safeDecreaseAllowance로 구현되어있는 사항이다.



Call without data

data가 필요하지 않고, 단순히 이더를 보내는 경우 send나 transfer가 더 안전하다.
2021년 5월부로 .call{value:}("") 방식이 send와 transfer보다 선호된다
Istanbul 하드포크 이전까지, send와 transfer는 호출에 사용되는 가스량을 2300으로 제한하였기 때문에 reentrancy 공격을 방지하는 효과가 있었다. 그래서 이왕이면 call보다 send나 transfer가 선호되었다.

SLOAD has historically been underpriced, and EIP 1884 rectifies that.

그러나 위와 같이 이후 특정 opcode의 비용이 변경되며, 가스비를 특정하는 방식은 더 이상 바람직한 방법이 아니게 되었다.
추후 비용이 증가하여 정상 컨트랙트임에도 (재진입 공격 없이도) 실행에 실패할 수도 있고, 비용이 감소하여 2300 가스만으로도 재진입 공격이 가능해질수도 있는 상황이었기 때문이다.

따라서 Istanbul 하드포크 이후 call.value("")("") 가 send / transfer보다 권장되다가, 이 문법이 바
귀어 현재는 call{value:"", gas:"", ...}("")이 권장되고 있다.
https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/

다만 smartcheck은 솔리디티 0.5를 기준으로 하고 있어서,, 위 항목이 취약점이다.



Uninitialized storage variable

초기화되지 않은 storage variable은 첫 state variable을 가리킨다.

contract Uninitialized{
    address owner = msg.sender;

    struct St{
        uint a;
    }

    function func() {
        St st;
        st.a = 0x0;
    }
}

위 코드에서 func()를 실행하면 초기화되지 않은 storage variable(struct이므로)인 st가 첫 state variable인 owner를 overwrite하게 된다.

해결방안> storage variable은 initialize한다.



Rewrite on assembly call

assembly call에서 값을 할당할 때, 빈 메모리에 써야한다. rewrite하면 문제 발생할 수 있다.

contract MixinSignatureValidator {
	// ... 생략 ...
        assembly {
            let cdStart := add(calldata, 32)
            let success := staticcall(
                gas,              // forward all gas
                walletAddress,    // address of Wallet contract
                cdStart,          // pointer to start of input
                mload(calldata),  // length of input
                cdStart,          // write output over input
                32                // output size is 32 bytes
            )
            }
	// ... 생략 ...
}

staticcall opcode는 (gas, address, input, input_length, output, output_length)를 인자로 취한다.
위 코드를 보면, input이 cdStart인데, output도 cdStart로 동일한 위치를 덮어씌우고 있다. 예기치 못하게 중간에 코드 실행이 중단될 경우 메모리가 corrupted될 수 있으며, 덮어씌우는 length가 word 단위에 맞지 않으면 추후에 잘못된 값을 사용(코더의 실수로)하게 될 가능성이 있다.

해결방안> 새로운 위치에 써라. 덮어씌우지 말 것.



Shadowed builtin

builtin / 예약어를 shadowing 하면 문제 발생할 수 있다..

pragma solidity ^0.4.24;

contract Bug {
    uint now; // Overshadows current time stamp.

    function assert(bool condition) public {
        // Overshadows built-in symbol for providing assertions.
    }

    function get_next_expiration(uint earlier_time) private returns (uint) {
        return now + 259200; // References overshadowed timestamp.
    }
}

위 코드에서 now는 원래 시간을 알려주는 builtin 표현인데 shawowing되어 결국 0이 된다.
컴파일러 단에서 Warning으로만 인식. 실제 컴파일에는 문제 없음



Dangerous strict equalities

strict하게 값을 비교하는 로직은 취약할 수 있다.

contract Crowdsale{
    function fund_reached() public returns(bool){
        return this.balance == 100 ether;
    }
}

위 코드에서, 아무리 100이더 달성 후 프론트 페이지를 내렸다 하더라도, 누군가가 0.0001이더라도 보내면 fund_reached()는 무조건 false가 된다.
아 이건 좀;;;

해결방안> strict하게 값을 비교하지 않는다.



Uninitialized local variable

initialize하지 않은 지역변수는 예상치 못한 결과를 야기할 수 있다.

contract Uninitialized is Owner{
    function withdraw() payable public onlyOwner{
        address to;
        to.transfer(this.balance)
    }
}

위 코드에서, withdraw()를 호출했는데, to가 비어있어서 이더가 공중분해된다.
이게 무슨 취약점이야.. 그냥 코딩 실수지

해결방안> 지역변수를 초기화한다.



Delete on dynamic array

매우 큰 동적 배열을 delete하면 out of gas가 발생할 수 있다..
그와중에 굳이 dynamic array라고 한 이유는 아마 static 배열은 크기를 알기 때문에 gas를 측정할 수 있는데 dynamic은 크기를 모르기 때문인 것으로 추정된다. ㅋㅋㅋ
아니 세상 이렇게 당연한 말을..



레퍼런스

각 툴 docs
https://solidity-by-example.org/
https://swcregistry.io/
https://github.com/ethereum/py-evm

(썸네일용 SWC 114 - TOD)

0개의 댓글