스마트컨트랙트 audit 툴을 최근 보고있다. Securify2, Smartcheck, Conkas라는 툴의 탐지 항목 중 정확히 이해하지 못한 것들을 정리해봤다.
개인적으로 취약점마다 뉘앙스나 층위가 좀 다르다고 느꼈다. 블록체인 구조 상의 취약점, 솔리디티 문법 상의 취약점이 혼재되어있고, 또 그것도 악의적인 행위 없이도 발생할 수 있는 취약점과, 악의적인 외부 행위가 있어야 발생하는 취약점으로 나뉘기 때문이다.
트랜잭션 순서에 따라 결과값이 달라질 수 있는 경우, 공격자가 자신의 트랜잭션 순서를 조정해 이득을 취할 수 있다. 블록에 싣는 과정에서의 Race Condition이라 볼 수 있다. 앞질러 트랜잭션을 성공시킨다는 점에서 frontrunning이라고도 한다.
시나리오>
<해결방안>
commit reveal hash scheme을 사용한다.
erc20 approve specific하게는, 현재 allowance를 직접 체크한 뒤 approve 한도를 inc/decrease하는 것이다.
이는 Openzeppelin SafeERC20.sol
에 이미 safeIncreaseAllowance
/ safeDecreaseAllowance
로 구현되어있는 사항이다.
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를 기준으로 하고 있어서,, 위 항목이 취약점이다.
초기화되지 않은 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한다.
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 단위에 맞지 않으면 추후에 잘못된 값을 사용(코더의 실수로)하게 될 가능성이 있다.
해결방안> 새로운 위치에 써라. 덮어씌우지 말 것.
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으로만 인식. 실제 컴파일에는 문제 없음
strict하게 값을 비교하는 로직은 취약할 수 있다.
contract Crowdsale{
function fund_reached() public returns(bool){
return this.balance == 100 ether;
}
}
위 코드에서, 아무리 100이더 달성 후 프론트 페이지를 내렸다 하더라도, 누군가가 0.0001이더라도 보내면 fund_reached()는 무조건 false가 된다.
아 이건 좀;;;
해결방안> strict하게 값을 비교하지 않는다.
initialize하지 않은 지역변수는 예상치 못한 결과를 야기할 수 있다.
contract Uninitialized is Owner{
function withdraw() payable public onlyOwner{
address to;
to.transfer(this.balance)
}
}
위 코드에서, withdraw()를 호출했는데, to가 비어있어서 이더가 공중분해된다.
이게 무슨 취약점이야.. 그냥 코딩 실수지
해결방안> 지역변수를 초기화한다.
매우 큰 동적 배열을 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)