이 기사는 스마트 계약 보안에 대한 미니 코스 역할을 하며 Solidity 스마트 계약에서 반복되는 경향이 있는 문제와 취약점에 대한 광범위한 목록을 제공합니다. 이는 품질 감사에서 발생할 수 있는 일종의 문제입니다.
Solidity의 보안 문제는 스마트 계약이 의도한 대로 작동하지 않는다는 점으로 귀결됩니다.
자금이 도난 당하고 있습니다.
계약 내에서 자금이 잠겨 있거나 동결됨
사람들이 예상보다 적은 보상을 받음(보상이 지연되거나 축소됨)
사람들은 예상보다 더 많은 보상을 받습니다(인플레이션과 평가절하로 이어짐).
잘못될 수 있는 모든 것을 포괄적인 목록으로 만드는 것은 불가능합니다. 그러나 전통적인 소프트웨어 엔지니어링에 SQL 주입, 버퍼 오버런, 크로스 사이트 스크립팅과 같은 취약점이라는 공통 주제가 있는 것처럼 스마트 계약에는 문서화할 수 있는 반복적인 안티 패턴이 있습니다.
이 가이드를 참고용으로 생각하세요. 이 책을 책으로 만들지 않고서는 모든 개념을 자세히 논의하는 것은 불가능합니다. 그러나 이는 무엇을 주의해야 하고 무엇을 연구해야 하는지에 대한 목록 역할을 합니다. 어떤 주제가 낯설게 느껴진다면, 이는 해당 취약성 클래스를 식별하는 데 시간을 투자할 가치가 있다는 지표로 작용해야 합니다.
이 문서에서는 Solidity 에 대한 기본적인 숙련도가 있다고 가정합니다. Solidity를 처음 사용하는 경우 무료 Solidity 튜토리얼을 참조하세요.
우리는 스마트 계약 재진입에 대해 광범위하게 썼으므로 여기서는 반복하지 않겠습니다. 그러나 다음은 간단한 요약입니다.
스마트 계약이 다른 스마트 계약의 기능을 호출하거나 Ether를 보내거나 토큰을 전송할 때마다 재진입 가능성이 있습니다.
재진입은 가장 잘 알려진 스마트 계약 취약점임에도 불구하고 실제로 발생하는 해킹 중 극히 일부에 불과합니다. 보안 연구원 Pascal Caversaccio(pcaveraccio)는 재진입 공격에 대한 최신 Github 목록을 유지합니다. 2023년 4월 현재 해당 저장소에는 46건의 재진입 공격이 문서화되어 있습니다.
단순한 실수처럼 보이지만 민감한 기능(이더 인출이나 소유권 변경 등)을 호출할 수 있는 사람을 제한하는 것을 잊어버리는 일이 놀라울 정도로 자주 발생합니다.
수정자가 있어도 아래 예시처럼 require 문이 누락되는 등 수정자가 올바르게 구현되지 않은 경우가 있었습니다.
// DO NOT USE! modifier onlyMinter { minters[msg.sender] == true_; }
위의 코드는 이번 감사의 실제 예입니다: https://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and- 토끼홀티켓-계약
액세스 제어가 잘못될 수 있는 또 다른 방법은 다음과 같습니다.
function claimAirdrop(bytes32 calldata proof[]) { bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender))); require(verified, "not verified"); require(alreadyClaimed[msg.sender], "already claimed"); _transfer(msg.sender, AIRDROP_AMOUNT); }
이 경우 "alreadyClaimed"는 true로 설정되지 않으므로 청구자는 함수 호출을 여러 번 실행할 수 있습니다.
액세스 제어가 불충분한 최근의 예는 트레이딩 봇(해당 시퀀스로 시작하는 주소에서 0xbad라는 이름으로 불림)을 통해 플래시론을 수신하는 보호되지 않은 기능이었습니다. 어느 날 공격자가 flashloan 제공업체뿐만 아니라 어떤 주소에서든 flashloan 수신 기능을 호출할 수 있다는 사실을 알아차릴 때까지 백만 달러가 넘는 수익을 올렸습니다.
일반적으로 거래 봇의 경우와 마찬가지로 거래를 실행하는 스마트 계약 코드는 검증되지 않았지만 공격자는 어쨌든 약점을 발견했습니다. rekt 뉴스 보도에서 더 많은 정보를 확인하세요.
액세스 제어가 누가 함수를 호출하는지 제어하는 것이라면 입력 검증은 계약을 호출하는 대상을 제어하는 것입니다.
이는 일반적으로 적절한 require 문을 제자리에 두는 것을 잊어버린 결과로 귀결됩니다.
다음은 기본적인 예입니다.
contract UnsafeBank { mapping(address => uint256) public balances; // allow depositing on other's behalf function deposit(address for) public payable { balances += msg.value; } function withdraw(address from, uint256 amount) public { require(balances[from] <= amount, "insufficient balance"); balances[from] -= amount; msg.sender.call{value: amout}(""); } }
위의 계약은 귀하가 귀하의 계좌에 있는 것보다 더 많이 인출하지 않는다는 것을 확인하지만 임의의 계좌에서 인출하는 것을 막지는 않습니다.
Sushiswap은 외부 기능의 매개변수 중 하나가 삭제되지 않아 이러한 유형의 해킹을 경험했습니다.
부적절한 액세스 제어는 msg.sender에 적절한 제한이 없음을 의미합니다. 부적절한 입력 유효성 검사는 함수에 대한 인수가 충분히 정리되지 않았음을 의미합니다. 이 안티패턴에는 반대되는 경우도 있습니다. 즉, 함수 호출에 너무 많은 제한을 가하는 것입니다.
과도한 검증은 아마도 자금이 도난 당하지 않는다는 것을 의미하지만 자금이 계약에 고정될 수 있음을 의미할 수 있습니다. 안전 장치를 너무 많이 마련하는 것도 좋지 않습니다.
가장 세간의 이목을 끄는 사건 중 하나는 Akutars NFT로, 스마트 계약 내에 3,400만 달러 상당의 Eth가 갇히고 출금이 불가능한 상황이 발생했습니다.
계약에는 네덜란드 경매 가격 이상으로 지불한 모든 환불이 완료될 때까지 계약 소유자가 계약을 철회하는 것을 방지하는 선의의 메커니즘이 있었습니다. 그러나 아래 링크된 트위터 스레드에 기록된 버그로 인해 소유자는 자금을 인출할 수 없었습니다.
Sushiswap은 신뢰할 수 없는 사용자에게 너무 많은 권한을 부여했고 Akutars NFT는 관리자에게 너무 적은 권한을 부여했습니다. 스마트 계약을 설계할 때 각 사용자 클래스에 어느 정도의 자유가 있어야 하는지에 대한 주관적인 판단이 필요하며, 이 결정은 자동화된 테스트 및 툴링에 맡길 수 없습니다. 고려해야 할 분산화, 보안 및 UX에는 상당한 장단점이 있습니다.
스마트 계약 프로그래머의 경우 사용자가 특정 기능을 사용하여 수행할 수 있는 작업과 수행할 수 없는 작업을 명시적으로 작성하는 것은 개발 프로세스의 중요한 부분입니다.
과도한 관리자에 대한 주제는 나중에 다시 다루겠습니다.
서문에서 언급했듯이 스마트 계약이 해킹되는 주요 방법에는 네 가지가 있습니다.
여기서 “돈”은 암호화폐뿐만 아니라 토큰과 같은 가치 있는 모든 것을 의미합니다. 스마트 계약을 코딩하거나 감사할 때 개발자는 가치가 계약에 들어오고 나가는 의도된 방식을 주의 깊게 살펴야 합니다. 위에 나열된 문제는 스마트 계약이 해킹되는 주요 방법이지만 아래에 문서화되어 있는 주요 문제로 이어질 수 있는 다른 근본 원인도 많이 있습니다.
바닐라 ERC20 토큰이나 NFT를 투표에 무게를 두는 티켓으로 사용하는 것은 공격자가 하나의 주소로 투표하고 토큰을 다른 주소로 전송한 후 해당 주소에서 다시 투표할 수 있기 때문에 안전하지 않습니다.
다음은 최소한의 예입니다.
// A malicious voter can simply transfer their tokens to // another address and vote again. contract UnsafeBallot { uint256 public proposal1VoteCount; uint256 public proposal2VoteCount; IERC20 immutable private governanceToken; constructor(IERC20 _governanceToken) { governanceToken = _governanceToken; } function voteFor1() external notAlreadyVoted { proposal1VoteCount += governanceToken.balanceOf(msg.sender); } function voteFor2() external notAlreadyVoted { proposal2VoteCount += governanceToken.balanceOf(msg.sender); } // prevent the same address from voting twice, // however the attacker can simply // transfer to a new address modifier notAlreadyVoted { require(!alreadyVoted[msg.sender], "already voted"); _; alreadyVoted[msg.sender] = true; } }
이러한 공격을 방지하려면 ERC20 Snapshot 또는 ERC20 Votes를 사용해야 합니다. 과거 특정 시점의 스냅샷을 생성함으로써 현재 토큰 잔액을 조작하여 불법 투표권을 얻을 수 없습니다.
그러나 스냅샷 또는 투표 기능이 있는 ERC20 토큰을 사용하면 누군가 플래시론을 받아 일시적으로 잔액을 늘린 다음 동일한 거래에서 잔액의 스냅샷을 찍을 수 있는 경우 문제가 완전히 해결되지 않습니다. 해당 스냅샷이 투표에 사용된다면, 그들은 불합리하게 많은 양의 투표권을 갖게 될 것입니다.
플래시론(flashloan)은 대량의 이더리움이나 토큰을 특정 주소에 빌려주지만, 동일한 거래에서 돈을 갚지 않으면 돌려받는다.
contract SimpleFlashloan { function borrowERC20Tokens() public { uint256 before = token.balanceOf(address(this)); // send tokens to the borrower token.transfer(msg.sender, amount); // hand control back to the borrower to // let them do something IBorrower(msg.sender).onFlashLoan(); // require that the tokens got returned require(token.balanceOf(address(this) >= before); } }
공격자는 플래시론을 사용하여 갑자기 많은 표를 얻어 제안을 자신에게 유리하게 바꾸거나 악의적인 행동을 할 수 있습니다.
이는 틀림없이 DeFi에 대한 가장 일반적인(또는 적어도 가장 주목받는) 공격으로, 수억 달러의 손실을 입혔습니다. 다음은 유명인의 목록 입니다.
블록체인의 자산 가격은 종종 자산 간의 현재 환율로 계산됩니다. 예를 들어, 계약이 현재 100 k9coin에 대해 1 USDC를 거래하고 있는 경우 k9coin의 가격은 0.01 USDC라고 말할 수 있습니다. 그러나 가격은 일반적으로 매수 및 매도 압력에 따라 움직이며, 플래시 대출은 엄청난 매수 및 매도 압력을 유발할 수 있습니다.
자산 가격에 대해 다른 스마트 계약에 문의할 때 개발자는 자신이 호출하는 스마트 계약이 플래시 대출 조작에 영향을 받지 않는다고 가정하기 때문에 매우 주의해야 합니다.
주소의 바이트코드 크기를 보면 해당 주소가 스마트 계약인지 “확인”할 수 있습니다. 외부 소유 계정(일반 지갑)에는 바이트코드가 없습니다. 이를 수행하는 몇 가지 방법은 다음과 같습니다.
import "@openzeppelin/contracts/utils/Address.sol" contract CheckIfContract { using Address for address; function addressIsContractV1(address _a) { return _a.code.length == 0; } function addressIsContractV2(address _a) { // use the openzeppelin libraryreturn _a.isContract(); } }
그러나 여기에는 몇 가지 제한 사항이 있습니다.
일반적으로 주소가 계약인지 확인하는 것은 일반적으로(항상 그런 것은 아니지만) 반패턴입니다. 다중 서명 지갑은 그 자체로 스마트 계약이며, 다중 서명 지갑을 깨뜨릴 수 있는 모든 작업을 수행하면 구성성이 깨집니다.
이에 대한 예외는 전송 후크를 호출하기 전에 대상이 스마트 계약인지 확인하는 것입니다. 이에 대해서는 나중에 자세히 설명합니다.
tx.origin을 사용해야 할 이유가 거의 없습니다. tx.origin을 사용하여 보낸 사람을 식별하면 중간자 공격이 가능합니다. 사용자가 속아서 악의적인 스마트 계약을 호출하게 되면 스마트 계약은 tx.origin이 혼란을 야기할 수 있는 모든 권한을 사용할 수 있습니다.
다음 연습과 코드 위의 주석을 고려하세요.
contract Phish { function phishingFunction() public { // this fails, because this contract does not have approval/allowance token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender)); // this also fails, because this creates approval for the contract,// not the wallet calling this phishing function token.approve(address(this), type(uint256).max); } }
이는 임의의 스마트 계약을 안전하게 호출할 수 있다는 의미는 아닙니다. 그러나 대부분의 프로토콜에는 인증에 tx.origin을 사용하는 경우 우회되는 안전 계층이 내장되어 있습니다.
때로는 다음과 같은 코드가 표시될 수 있습니다.
require(msg.sender == tx.origin, "no contracts");
스마트 계약이 다른 스마트 계약을 호출하는 경우 msg.sender는 스마트 계약이 되고 tx.origin은 사용자의 지갑이 되어 수신 전화가 스마트 계약에서 온 것이라는 신뢰할 수 있는 표시를 제공합니다. 이는 생성자에서 호출이 발생하는 경우에도 마찬가지입니다.
대부분의 경우 이 디자인 패턴은 좋은 생각이 아닙니다. 다중 서명 지갑과 EIP 4337의 지갑은 이 코드가 있는 기능과 상호 작용할 수 없습니다. 이 패턴은 NFT 민트에서 흔히 볼 수 있으며, 대부분의 사용자가 기존 지갑을 사용한다고 예상하는 것이 합리적입니다. 그러나 계정 추상화가 대중화되면서 이 패턴은 도움이 되기보다는 방해가 될 것입니다.
슬픔을 주는 공격은 해커가 다른 사람에게 "슬픔을 야기"하려고 한다는 의미입니다. 비록 그렇게 함으로써 경제적 이익을 얻지 못하더라도 말이죠.
스마트 계약은 무한 루프에 빠져 전달된 모든 가스를 악의적으로 사용할 수 있습니다. 다음 예를 고려하십시오.
contract Mal { fallback() external payable { // infinite loop uses up all the gas while (true) { } } }
다른 계약이 다음과 같은 주소 목록에 Ether를 배포하는 경우:
contract Distribute { funtion distribute(uint256 total) public nonReentrant { for (uint i; i < addresses.length; ) { (bool ok, ) addresses.call{value: total / addresses.length}(""); // ignore ok, if it reverts we move on // traditional gas saving trick for for loops unchecked { ++i; } } } }
그런 다음 이더를 Mal로 보내면 함수가 되돌려집니다. 위 코드의 호출은 사용 가능한 가스의 63/64를 전달하므로 가스의 1/64만 남은 상태에서 작업을 완료하는 데 가스가 충분하지 않을 가능성이 높습니다.
스마트 계약은 많은 가스를 소비하는 대규모 메모리 배열을 반환할 수 있습니다.
다음 예를 고려하십시오
function largeReturn() public { // result might be extremely long! (book ok, bytes memory result) = otherContract.call(abi.encodeWithSignature("foo()")); require(ok, "call failed"); }
메모리 배열은 724바이트 이후에 2차 양의 가스를 사용하므로 신중하게 선택한 반환 데이터 크기는 호출자를 괴롭힐 수 있습니다.
변수 결과를 사용하지 않더라도 여전히 메모리에 복사됩니다. 반품 크기를 특정 금액으로 제한하려면 어셈블리를 사용할 수 있습니다.
function largeReturn() public { assembly { let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00); // nothing is copied to memory until you // use returndatacopy() } }
스토리지 삭제는 가스 효율적인 작업이지만 여전히 순비용이 발생합니다. 배열이 너무 길어지면 삭제할 수 없게 됩니다. 다음은 최소한의 예입니다.
contract VulnerableArray { address[] public stuff; function addSomething(address something) public { stuff.push(something); } // if stuff is too long, this will become undeletable due to // the gas cost function deleteEverything() public onlyOwner { delete stuff; } }
스마트 계약이 전송 후크가 있는 토큰을 전송하는 경우 공격자는 토큰을 허용하지 않는 계약을 설정할 수 있습니다(onReceive 기능이 없거나 되돌리도록 기능을 프로그래밍하지 않음). 이렇게 하면 토큰을 양도할 수 없게 되고 전체 거래가 되돌려지게 됩니다.
safeTransfer 또는 전송을 사용하기 전에 수신자가 트랜잭션을 강제로 되돌릴 가능성을 고려하십시오.
contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver { // this will intercept any transfer hook fallback() external payable { // infinite loop uses up all the gaswhile (true) { } } // we could also selectively deny transactions function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { if (wakeUpChooseViolence()) { revert(); } else { return IERC721Receiver.onERC721Received.selector; } } }
현재 블록체인에서 단일 트랜잭션으로 무작위성을 안전하게 생성하는 것은 불가능합니다. 블록체인은 완전히 결정적이어야 합니다. 그렇지 않으면 분산 노드가 상태에 대한 합의에 도달할 수 없습니다. 완전히 결정적이기 때문에 "무작위" 숫자를 예측할 수 있습니다. 다음과 같은 주사위 굴리기 기능을 활용할 수 있습니다.
contract UnsafeDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable { require(msg.value == 1 ether); if (randomness() % 6) == 5) { msg.sender.call{value: 2 ether}(""); } } } contract ExploitDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } function betSafely(IUnsafeDice game) public payable { if (randomness % 6) == 5)) { game.betSafely{value: 1 ether}() } // else don't do anything } }
공격자가 무작위성을 정확하게 복제할 수 있기 때문에 무작위성을 어떻게 생성하는지는 중요하지 않습니다. msg.sender, 타임스탬프 등과 같은 "엔트로피"의 더 많은 소스를 던지는 것은 스마트 계약이 이를 두 가지로 측정할 수 있기 때문에 아무런 효과가 없습니다.
Chainlink는 안전한 난수를 얻는 데 널리 사용되는 솔루션입니다. 두 단계로 수행됩니다. 먼저 스마트 계약이 오라클에 임의성 요청을 보낸 다음 몇 블록 후에 오라클이 임의의 숫자로 응답합니다.
공격자는 미래를 예측할 수 없기 때문에 난수도 예측할 수 없습니다.
스마트 계약이 오라클을 잘못 사용하지 않는 한.
Chainlink는 특정 기간 내에 가격 오라클을 최신 상태로 유지하기 위한 SLA(서비스 수준 계약)가 없습니다. 체인이 심각하게 혼잡한 경우(예: Yuga Labs Otherside 민트가 거래가 진행되지 않을 정도로 이더리움을 압도하는 경우) 가격 업데이트가 지연될 수 있습니다.
가격 오라클을 사용하는 스마트 계약은 데이터가 오래되지 않았는지, 즉 최근에 일부 임계값 내에서 업데이트되었는지 명시적으로 확인해야 합니다. 그렇지 않으면 가격과 관련하여 신뢰할 수 있는 결정을 내릴 수 없습니다.
가격이 편차 임계값을 넘어서 변경되지 않으면 오라클이 가스를 절약하기 위해 가격을 업데이트하지 않을 수 있으므로 이는 "부실"로 간주되는 시간 임계값에 영향을 미칠 수 있다는 추가적인 복잡성이 있습니다.
스마트 계약이 의존하는 오라클의 SLA를 이해하는 것이 중요합니다.
오라클이 아무리 안전해 보이더라도 미래에는 공격이 발견될 수 있습니다. 이에 대한 유일한 방어는 여러 개의 독립적인 오라클을 사용하는 것입니다.
블록체인은 매우 안전할 수 있지만, 우선 데이터를 체인에 저장하려면 블록체인이 제공하는 모든 보안 보장을 포기하는 일종의 오프체인 작업이 필요합니다. 오라클이 정직하더라도 데이터 소스는 조작될 수 있습니다. 예를 들어, 오라클은 중앙화된 거래소에서 가격을 안정적으로 보고할 수 있지만 대규모 매수 및 매도 주문으로 가격이 조작될 수 있습니다. 마찬가지로, 센서 데이터나 일부 web2 API에 의존하는 오라클은 전통적인 해킹 벡터의 영향을 받습니다.
좋은 스마트 계약 아키텍처는 가능한 경우 오라클의 사용을 완전히 피합니다.
다음 계약을 고려하십시오.
contract MixedAccounting { uint256 myBalance; function deposit() public payable { myBalance = myBalance + msg.value; } function myBalanceIntrospect() public view returns (uint256) { return address(this).balance; } function myBalanceVariable() public view returns (uint256) { return myBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }
위 컨트랙트에는 수신 또는 대체 기능이 없으므로 Ether를 직접 전송하면 되돌려집니다. 그러나 계약은 강제로 selfdestruct를 통해 Ether를 보낼 수 있습니다.
이 경우 myBalanceIntrospect()는 myBalanceVariable()보다 큽니다. Ether 회계 방법은 괜찮지만 둘 다 사용하면 계약서의 동작이 일관되지 않을 수 있습니다.
ERC20 토큰에도 동일하게 적용됩니다.
contract MixedAccountingERC20 { IERC20 token; uint256 myTokenBalance; function deposit(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); myTokenBalance = myTokenBalance + amount; } function myBalanceIntrospect() public view returns (uint256) { return token.balanceOf(address(this)); } function myBalanceVariable() public view returns (uint256) { return myTokenBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }
이번에도 myBalanceIntrospect()와 myBalanceVariable()이 항상 동일한 값을 반환한다고 가정할 수는 없습니다. myTokenBalance 변수를 업데이트하지 않고 입금 기능을 우회하여 ERC20 토큰을 MixedAccountingERC20으로 직접 전송할 수 있습니다.
자기 성찰로 잔액을 확인할 때, 잔액은 외부인에 의해 마음대로 바뀔 수 있으므로 엄격한 사용 평등 검사는 피해야 합니다.
이는 Solidity의 문제가 아니며 암호화를 사용하여 주소에 특별한 권한을 부여하는 방법에 대한 개발자 사이의 일반적인 오해에 가깝습니다. 다음 코드는 안전하지 않습니다
contract InsecureMerkleRoot { bytes32 merkleRoot; function airdrop(bytes[] calldata proof, bytes32 leaf) external { require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified"); require(!alreadyClaimed[leaf], "already claimed airdrop"); alreadyClaimed[leaf] = true; mint(msg.sender, AIRDROP_AMOUNT); } }
암호화 증명(머클 트리, 서명 등)은 공격자가 개인 키를 획득하지 않고는 조작할 수 없는 msg.sender에 연결되어야 합니다.
function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = a * b; }
product는 uint256 변수이지만 곱셈 결과는 255보다 클 수 없으며 그렇지 않으면 코드가 되돌아갑니다.
이 문제는 각 변수를 개별적으로 업캐스팅하여 완화할 수 있습니다.
function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = uint256(a) * uint256(b); }
이와 같은 상황은 구조체에 압축된 정수를 곱하는 경우 발생할 수 있습니다. 구조체에 포함된 작은 값을 곱할 때 이 점에 유의해야 합니다.
struct Packed { uint8 time; uint16 rewardRate } //... Packed p; p.time * p.rewardRate; // this might revert!
Solidity는 정수를 더 작은 정수로 캐스팅하는 것이 안전한지 확인하지 않습니다. 일부 비즈니스 로직이 다운캐스팅의 안전을 보장하지 않는 한 SafeCast 와 같은 라이브러리를 사용해야 합니다.
function test(int256 value) public pure returns (int8) { return int8(value + 1); // overflows and does not revert }
코드는 myArray[1]의 데이터를 myArray[0]에 복사하는 것처럼 보이지만 그렇지 않습니다. 함수의 마지막 줄을 주석 처리하면 컴파일러는 함수를 뷰 함수로 전환해야 한다고 말합니다. foo에 쓰기는 기본 저장소에 쓰지 않습니다.
contract DoesNotWrite { struct Foo { uint256 bar; } Foo[] public myArray; function moveToSlot0() external { Foo storage foo = myArray[0]; foo = myArray[1]; // myArray[0] is unchanged // we do this to make the function a state // changing operation // and silence the compiler warning myArray[1] = Foo({bar: 100}); } }
따라서 저장 포인터에 쓰지 마십시오.
매핑(또는 동적 배열)이 구조체 내부에 있고 구조체가 삭제된 경우 매핑이나 배열은 삭제되지 않습니다.
배열 삭제를 제외하고 delete 키워드는 하나의 스토리지 슬롯만 삭제할 수 있습니다. 저장소 슬롯에 다른 저장소 슬롯에 대한 참조가 포함된 경우 해당 항목은 삭제되지 않습니다.
contract NestedDelete { mapping(uint256 => Foo) buzz; struct Foo { mapping(uint256 => uint256) bar; } Foo foo; function addToFoo(uint256 i) external { buzz[i].bar[5] = 6; } function getFromFoo(uint256 i) external view returns (uint256) { return buzz[i].bar[5]; } function deleteFoo(uint256 i) external { // internal map still holds the data in the // mapping and array delete buzz[i]; } }
Solidity에서는 맵이 결코 "비어있지" 않습니다. 따라서 누군가가 삭제된 항목에 액세스하면 트랜잭션이 되돌려지지 않고 대신 해당 데이터 유형에 대해 0 값을 반환합니다.
신뢰할 수 있는 ERC20 토큰만 다루는 경우 이러한 문제의 대부분은 적용되지 않습니다. 그러나 임의적이거나 부분적으로 신뢰할 수 없는 ERC20 토큰과 상호 작용할 때 주의해야 할 몇 가지 사항이 있습니다.
신뢰할 수 없는 토큰을 처리할 때 잔액이 반드시 금액만큼 증가한다고 가정해서는 안 됩니다. ERC20 토큰은 다음과 같이 전송 기능을 구현할 수 있습니다.
contract ERC20 { // internally called by transfer() and transferFrom() // balance and approval checks happen in the caller function _transfer(address from, address to, uint256 amount) internal returns (bool) { fee = amount * 100 / 99; balanceOf[from] -= to; balanceOf[to] += (amount - fee); balanceOf[TREASURY] += fee; emit Transfer(msg.sender, to, (amount - fee)); return true; } }
이 토큰은 모든 거래에 1%의 세금을 적용합니다. 따라서 스마트 계약이 다음과 같이 토큰과 상호 작용하면 예상치 못한 수익을 얻거나 돈을 훔칠 수 있습니다.
contract Stake { mapping(address => uint256) public balancesInContract; function stake(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); balancesInContract[msg.sender] += amount; // THIS IS WRONG! } function unstake() public { uint256 toSend = balancesInContract[msg.sender]; delete balancesInContract[msg.sender]; // this could revert because toSend is 1% greater than// the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors. token.transfer(msg.sender, toSend); } }
리베이스 토큰은 Olympus DAO 의 sOhm 토큰과 Ampleforth의 AMPL 토큰에 의해 대중화되었습니다. Coingecko는 ERC20 토큰 리베이스 목록 을 유지 관리합니다.
토큰이 리베이스되면 총 공급량이 변경되고 리베이스 방향에 따라 모든 사람의 잔액이 증가하거나 감소합니다.
다음 코드는 리베이스 토큰을 처리할 때 중단될 가능성이 높습니다.
contract WillBreak { mapping(address => uint256) public balanceHeld; IERC20 private rebasingToken function deposit(uint256 amount) external { balanceHeld[msg.sender] = amount; rebasingToken.transferFrom(msg.sender, address(this), amount); } function withdraw() external { amount = balanceHeld[msg.sender]; delete balanceHeld[msg.sender]; // ERROR, amount might exceed the amount // actually held by the contract rebasingToken.transfer(msg.sender, amount); } }
많은 계약의 해결책은 단순히 토큰 리베이스를 허용하지 않는 것입니다. 그러나 계정 잔액을 보낸 사람에게 전송하기 전에 BalanceOf(address(this))를 확인하도록 위의 코드를 수정할 수 있습니다. 그러면 잔액이 변경되더라도 계속 작동합니다.
ERC20, 표준에 따라 구현된 경우 ERC20 토큰에는 전송 후크가 없으므로 transfer 및 transferFrom 에는 재진입 문제가 없습니다.
전송 후크가 있는 토큰에는 의미 있는 이점이 있습니다. 이것이 바로 모든 NFT 표준이 이를 구현하고 ERC777이 완성된 이유입니다. 그러나 Openzeppelin이 ERC777 라이브러리를 더 이상 사용하지 않는다는 것은 많은 혼란을 야기했습니다.
프로토콜이 ERC20 토큰처럼 작동하지만 전송 후크가 있는 토큰과 호환되도록 하려면 transfer 및 transferFrom 함수를 수신자에게 함수 호출을 발행하는 것처럼 처리하면 됩니다.
이 ERC777 재진입은 Uniswap에서 발생했습니다(궁금하신 경우 Openzeppelin에서 이 익스플로잇을 여기에 문서화했습니다).
ERC20 사양에서는 전송이 성공하면 ERC20 토큰이 true를 반환해야 한다고 규정합니다. 허용량이 충분하지 않거나 전송된 금액이 너무 많지 않으면 대부분의 ERC20 구현은 실패할 수 없기 때문에 대부분의 개발자는 ERC20 토큰의 반환 값을 무시하고 실패한 전송이 되돌릴 것이라고 가정하는 데 익숙해졌습니다.
솔직히 말해서, 동작을 알고 있는 신뢰할 수 있는 ERC20 토큰으로만 작업하는 경우에는 이것이 중요하지 않습니다. 그러나 임의의 ERC20 토큰을 처리할 때는 이러한 동작의 차이를 고려해야 합니다.
대부분의 ERC20 토큰에는 false를 반환하는 메커니즘이 없기 때문에 실패한 전송은 false를 반환하지 않고 항상 되돌려야 한다는 암묵적인 기대가 많은 계약에 존재하므로 이로 인해 많은 혼란이 발생합니다.
이 문제를 더욱 복잡하게 만드는 것은 일부 ERC20 토큰, 특히 Tether가 true를 반환하는 프로토콜을 따르지 않는다는 것입니다. 일부 토큰은 전송 실패 시 되돌려지며, 이로 인해 호출자에게 되돌리기가 표시됩니다. 따라서 일부 라이브러리는 ERC20 토큰 전송 호출을 래핑하여 되돌리기를 가로채고 대신 부울을 반환합니다.
Solady SafeTransfer (상당히 더 높은 가스 효율)
이는 스마트 계약 취약점은 아니지만 완전성을 위해 여기서 언급합니다.
사양에서는 제로 ERC20 토큰 전송이 허용됩니다. 이는 프런트엔드 애플리케이션에 혼란을 야기할 수 있으며 최근에 토큰을 보낸 사람이 누구인지에 대해 사용자를 속일 수 있습니다. Metamask 에는 이 스레드 에 더 많은 내용이 있습니다.
(web3 용어로 "러기드(rugged)"는 "당신 아래에서 러그를 뽑아낸 것"을 의미합니다.)
누군가가 ERC20 토큰에 원하는 대로 토큰을 생성, 전송 및 소각할 수 있는 기능을 추가하거나 자체 파괴 또는 업그레이드하는 것을 막을 수는 없습니다. 따라서 근본적으로 ERC20 토큰을 "신뢰할 수 없는" 정도에는 한계가 있습니다.
대출 기반 DeFi 프로토콜이 어떻게 중단될 수 있는지 고려할 때 버그가 소프트웨어 수준에서 전파되고 비즈니스 로직 수준에 영향을 미치는 것을 생각하는 것이 도움이 됩니다. 채권 계약을 형성하고 종료하는 데는 여러 단계가 있습니다. 고려해야 할 몇 가지 공격 벡터는 다음과 같습니다.
담보물이 프로토콜에서 고갈되면 대출자와 대출자 모두 손실을 입게 됩니다. 대출자는 대출금을 상환할 인센티브가 없고 대출자는 원금을 잃기 때문입니다.
위에서 볼 수 있듯이 DeFi 프로토콜에는 프로토콜에서 많은 돈이 빠져나가는 것(보통 뉴스를 만드는 종류의 이벤트)보다 "해킹"되는 수준이 훨씬 더 많습니다. 이는 CTF(플래그 탈취) 보안 훈련이 오해를 불러일으킬 수 있는 영역 중 하나입니다. 프로토콜 자금이 도난당하는 것은 가장 치명적인 결과이지만, 이를 방어할 수 있는 유일한 것은 결코 아닙니다.
외부 스마트 계약을 호출하는 방법에는 두 가지가 있습니다. 1) 인터페이스 정의를 사용하여 함수를 호출합니다. 2) .call 메소드를 사용합니다. 이는 아래에 설명되어 있습니다.
contract A { uint256 public x; function setx(uint256 _x) external { require(_x > 10, "x must be bigger than 10"); x = _x; } } interface IA { function setx(uint256 _x) external; } contract B { function setXV1(IA a, uint256 _x) external { a.setx(_x); } function setXV2(address a, uint256 _x) external { (bool success, ) = a.call(abi.encodeWithSignature("setx(uint256)", _x)); // success is not checked! } }
계약 B에서 _x가 10보다 작으면 setXV2는 자동으로 실패할 수 있습니다. 함수가 .call 메서드를 통해 호출되면 피호출자는 되돌릴 수 있지만 상위는 되돌리지 않습니다. 성공 값을 확인해야 하며 이에 따라 코드 동작이 분기되어야 합니다.
개인 변수는 여전히 블록체인에 표시되므로 민감한 정보를 블록체인에 저장해서는 안 됩니다. 액세스할 수 없다면 유효성 검사기는 해당 값에 의존하는 트랜잭션을 어떻게 처리할 수 있습니까? 개인 변수는 외부 Solidity 계약에서 읽을 수 없지만 Ethereum 클라이언트를 사용하여 오프체인에서 읽을 수 있습니다.
변수를 읽으려면 해당 저장 슬롯을 알아야 합니다. 다음 예에서 myPrivateVar의 스토리지 슬롯은 0입니다.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PrivateVarExample { uint256 private myPrivateVar; constructor(uint256 _initialValue) { myPrivateVar = _initialValue; } }
배포된 스마트 계약의 개인 변수를 읽는 자바스크립트 코드는 다음과 같습니다.
const Web3 = require("web3"); const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address async function readPrivateVar() { const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL // Read storage slot 0 (where 'myPrivateVar' is stored) const storageSlot = 0; const privateVarValue = await web3.eth.getStorageAt( PRIVATE_VAR_EXAMPLE_ADDRESS, storageSlot ); console.log("Value of private variable 'myPrivateVar':", web3.utils.hexToNumberString(privateVarValue)); } readPrivateVar();
Delegatecall은 모든 제어권을 Delegatecallee에게 넘겨주므로 신뢰할 수 없는 계약과 함께 사용해서는 안 됩니다. 이 예에서는 신뢰할 수 없는 계약이 계약의 모든 이더를 훔칩니다.
contract UntrustedDelegateCall { constructor() payable { require(msg.value == 1 ether); } function doDelegateCall(address _delegate, bytes calldata data) public { (bool ok, ) = _delegate.delegatecall(data); require(ok, "delegatecall failed"); } } contract StealEther { function steal() public { // you could also selfdestruct here // if you really wanted to be mean (bool ok,) = tx.origin.call{value: address(this).balance}(""); require(ok); } function attack(address victim) public { UntrustedDelegateCall(victim).doDelegateCall( address(this), abi.encodeWithSignature("steal()")); } }
우리는 단일 섹션에서 이 주제를 정의할 수 없습니다. 대부분의 업그레이드 버그는 일반적으로 Openzeppelin의 hardhat 플러그인을 사용하고 어떤 문제로부터 보호하는지 읽어보면 피할 수 있습니다. ( https://docs.openzeppelin.com/upgrades-plugins/1.x/ ).
계약에 소유자나 관리자가 있다고 해서 그들의 권한이 무제한이어야 한다는 의미는 아닙니다. NFT를 고려해보세요. 소유자만이 NFT 판매 수익을 인출하는 것이 합리적이지만, 소유자의 개인 키가 손상되면 계약을 일시 중지(블록 전송)할 수 있어 큰 피해를 입을 수 있습니다. 일반적으로 관리자 권한은 불필요한 위험을 최소화하기 위해 가능한 한 최소화되어야 합니다.
계약 소유권에 대해 말하자면…
이는 기술적으로 취약점은 아니지만 OpenZeppelin 소유는 소유권이 존재하지 않는 주소로 이전되는 경우 계약 소유권을 상실할 수 있습니다. Ownable2step에서는 수신자가 소유권을 확인해야 합니다. 이는 실수로 잘못 입력된 주소로 소유권을 보내는 것을 방지합니다.
Solidity에는 부동 소수점이 없으므로 반올림 오류가 불가피합니다. 디자이너는 반올림하는 것이 옳은 일인지, 반올림하는 것이 옳은 일인지, 그리고 반올림이 누구에게 유리해야 하는지를 의식해야 합니다.
나눗셈은 항상 마지막에 수행되어야 합니다. 다음 코드는 소수 자릿수가 다른 스테이블코인 간에 잘못 변환됩니다. 다음 교환 메커니즘을 통해 사용자는 dai(소수점 18자리)로 교환할 때 소량의 USDC(소수점 6자리)를 무료로 받을 수 있습니다. daiToTake 변수는 0으로 반내림되어 0이 아닌 usdcAmount에 대한 대가로 사용자로부터 아무것도 가져가지 않습니다.
contract Exchange { uint256 private constant CONVERSION = 1e12; function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) { uint256 daiToTake = usdcAmount / CONVERSION; conductSwap(daiToTake, usdcAmount); } }
Etheruem(및 유사한 체인)의 맥락에서 선행 거래는 보류 중인 거래를 관찰하고 더 높은 가스 가격을 지불하여 그 전에 다른 거래를 실행하는 것을 의미합니다. 즉, 공격자가 트랜잭션을 "앞서 실행"한 것입니다. 거래가 수익성 있는 거래인 경우 더 높은 가스 가격을 지불하는 것을 제외하고 거래를 정확하게 복사하는 것이 합리적입니다. 이러한 현상을 MEV라고도 부르는데, 이는 채굴자 추출 가능 가치를 의미하지만 때로는 다른 맥락에서 최대 추출 가능 가치를 의미하기도 합니다. 블록 생산자는 거래를 재정렬하고 자신의 거래를 삽입할 수 있는 무제한의 권한을 가지고 있으며, 역사적으로 이더리움이 지분 증명을 진행하기 전에 블록 생산자는 채굴자였습니다. 따라서 이름이 붙여졌습니다.
스마트 계약에서 Ether를 인출하는 것은 "수익성 있는 거래"로 간주될 수 있습니다. 가스 비용을 제외하고 비용이 전혀 들지 않는 거래를 실행하면 처음보다 더 많은 암호화폐를 얻게 됩니다.
contract UnprotectedWithdraw { constructor() payable { require(msg.value == 1 ether, "must create with 1 eth"); } function unsafeWithdraw() external { (bool ok, ) = msg.sender.call{value: address(this).value}(""); require(ok, "transfer failed"). } }
이 계약을 배포하고 철회를 시도하면 선두 봇이 멤풀에서 "unsafeWithdraw"에 대한 호출을 알아차리고 이를 복사하여 Ether를 먼저 얻습니다.
우리는 ERC4626 튜토리얼 에서 ERC-4626 인플레이션 공격에 대해 심층적으로 작성했습니다. 그러나 그 요점은 ERC4626 계약이 거래자가 기여하는 "자산"의 비율에 따라 "공유" 토큰을 배포한다는 것입니다.
대략적으로 다음과 같이 작동합니다.
function getShares(...) external { // code shares_received = assets_contributed / total_assets; // more code }
물론 누구도 자산을 기부하고 주식을 돌려받지 못할 것입니다. 그러나 누군가가 주식을 얻기 위해 거래를 선점할 수 있다면 그런 일이 일어날 것이라고 예측할 수는 없습니다.
예를 들어, 풀에 20개의 자산이 있을 때 200개의 자산을 기부하면 100주를 얻을 것으로 기대합니다. 그러나 누군가가 200개 자산을 예치하기 위해 거래를 선취하면 공식은 200/220이 되며, 이는 0으로 반내림되어 피해자는 자산을 잃고 0주식을 돌려받게 됩니다.
추상적으로 설명하기보다는 실제 사례를 통해 설명하는 것이 가장 좋습니다.
이제 Eve는 100개나 50개가 아닌 150개의 토큰을 갖게 됩니다. 이에 대한 해결책은 신뢰할 수 없는 승인을 처리할 때 승인을 늘리거나 줄이기 전에 승인을 0으로 설정하는 것입니다.
자산 가격은 매수 및 매도 압력에 따라 움직입니다. 대량 주문이 멤풀에 있는 경우 거래자는 주문을 복사할 동기가 있지만 가스 가격은 더 높습니다. 이런 식으로 그들은 자산을 구매하고 대량 주문으로 인해 가격이 올라가도록 한 다음 즉시 판매합니다. 매도 주문을 "백런닝(backrunning)"이라고도 합니다. 매도 주문은 더 낮은 가스 가격으로 매도 주문을 하여 다음과 같이 진행됩니다.
이 공격에 대한 기본 방어는 "슬리피지(slippage)" 매개변수를 제공하는 것입니다. "선점 매수" 자체가 가격을 특정 기준점 이상으로 끌어올리면 "대량 매수" 주문이 되돌아가서 선두 주자가 거래에서 실패하게 됩니다.
대량 매수가 프런트런 매수와 백런 매도에 의해 샌드위치되기 때문에 샌드위치라고 불립니다. 이 공격은 반대 방향으로 대규모 매도 주문에도 적용됩니다.
Frontrunning은 엄청난 주제입니다. Flashbots는 이 주제를 광범위하게 연구하고 부정적인 외부 효과를 최소화하는 데 도움이 되는 여러 도구와 연구 기사를 발표했습니다.
적절한 블록체인 아키텍처를 사용하여 프론트러닝을 "설계"할 수 있는지 여부는 아직 결정적으로 해결되지 않은 논쟁의 대상입니다. 다음 두 기사는 이 주제에 대한 지속적인 고전입니다.
디지털 서명은 스마트 계약과 관련하여 두 가지 용도로 사용됩니다.
다음은 사용자에게 NFT 발행 권한을 부여하기 위해 디지털 서명을 안전하게 사용하는 예입니다.
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721("name", "symbol") { function mint(bytes calldata signature) external { address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature); require(recovered == authorizer, "signature does not match"); } }
전형적인 예는 ERC20의 승인 기능입니다. 우리 계정에서 일정량의 토큰을 인출하기 위한 주소를 승인하려면 가스 비용이 드는 실제 이더리움 거래를 해야 합니다.
때때로 수신자에게 오프체인으로 디지털 서명을 전달하는 것이 더 효율적일 수 있습니다. 그런 다음 수신자는 스마트 계약에 서명을 제공하여 거래를 수행할 권한이 있음을 증명합니다.
ERC20Permit은 디지털 서명으로 승인을 가능하게 합니다. 기능은 다음과 같이 설명됩니다.
function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public
실제 승인 거래를 보내는 대신 소유자는 지출자에 대한 승인에 (마감일과 함께) "서명"할 수 있습니다. 그러면 승인된 지출자는 제공된 매개변수를 사용하여 허가 기능을 호출할 수 있습니다.
변수 v, r 및 s를 자주 볼 수 있습니다. 이들은 각각 uint8, bytes32 및 bytes32 데이터 유형으로 견고하게 표현됩니다. 때때로 서명은 abi.encodePacked(r, s, v);로 함께 연결된 모든 값인 65바이트 배열로 표시됩니다.
서명의 다른 두 가지 필수 구성 요소는 메시지 해시(32바이트)와 서명 주소입니다. 순서는 이렇습니다
개인 키(privKey)는 공개 주소(ethAddress)를 생성하는 데 사용됩니다.
스마트 계약은 ethAddress를 미리 저장합니다.
오프체인 사용자는 메시지를 해시하고 해시에 서명합니다. 이는 msgHash 쌍과 서명(r, s, v)을 생성합니다.
스마트 계약은 메시지를 수신하고 이를 해시하여 msgHash를 생성한 다음 이를 (r, s, v)와 결합하여 어떤 주소가 나오는지 확인합니다.
주소가 ethAddress와 일치하면 서명이 유효합니다(곧 보게 될 특정 가정 하에서!).
스마트 계약은 4단계에서 미리 컴파일된 계약 erecover를 사용하여 조합이라고 부르는 작업을 수행하고 주소를 다시 가져옵니다.
이 과정에는 일이 옆으로 진행될 수 있는 많은 단계가 있습니다.
초기화되지 않은 변수를 ecrecover의 출력과 비교할 경우 취약점이 발생할 수 있습니다.
이 코드는 취약합니다
contract InsecureContract { address signer; // defaults to address(0) // who lets us give the beneficiary the airdrop without them// spending gas function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // ecrecover returns address(0) if the signature is invalid require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature"); mint(msg.sender, AIRDROP_AMOUNT); } }
서명 재생은 이전에 서명이 사용되었는지 계약이 추적하지 않을 때 발생합니다. 다음 코드에서는 이전 문제를 해결했지만 여전히 안전하지 않습니다.
contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); mint(msg.sender, amount); } }
사람들은 원하는만큼 에어드랍을 청구할 수 있습니다!
다음 줄을 추가할 수 있습니다.
bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // mapping(bytes => bool); used[signature] = true;
아쉽게도 코드는 아직 안전하지 않습니다!
유효한 서명이 주어지면 공격자는 빠른 연산을 수행하여 다른 서명을 파생할 수 있습니다. 그런 다음 공격자는 이 수정된 서명을 "재생"할 수 있습니다. 하지만 먼저 유효한 서명으로 시작하여 이를 수정하고 새 서명이 여전히 통과됨을 보여줄 수 있음을 보여주는 몇 가지 코드를 제공하겠습니다.
contract Malleable { // v = 28 // r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204 // s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86 // m = 0x0000000000000000000000000000000000000000000000000000000000000003 function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){ bytes32 h = keccak256(msg); address a = ecrecover(h, v, r, s); // The following is math magic to invert the // signature and create a valid one // flip s bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s)); // invert v uint8 v2; require(v == 27 || v == 28, "invalid v"); v2 = v == 27 ? 28 : 27; address b = ecrecover(h, v2, r, s2); assert(a == b); // different signatures, same address!; return (a, b); } }
따라서 우리가 실행 중인 예제는 여전히 취약합니다. 누군가 유효한 서명을 제시하면 미러 이미지 서명이 생성되어 사용된 서명 확인을 우회할 수 있습니다.
contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // this can be bypassed used[signature] = true; mint(msg.sender, amount); } }
이 시점에서 아마도 보안 서명 코드가 필요할 것입니다. 그렇죠? Solidity에서 서명을 생성 하고 파운드리에서 테스트하는 방법에 대한 튜토리얼을 참조하세요.
하지만 여기에 체크리스트가 있습니다.
위의 공격은 해싱이 체인에서 수행되지 않으면 더욱 일반화될 수 있습니다. 위 예시에서는 해싱이 스마트 컨트랙트에서 이루어졌기 때문에 위 예시는 다음 익스플로잇에 취약하지 않습니다.
서명을 복구하는 코드를 살펴보겠습니다.
// this code is vulnerable! function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) { require(signer == ecrecover(hash, v, r, s), "signer does not match"); // more actions }
사용자는 해시와 서명을 모두 제공합니다. 공격자가 이미 서명자의 유효한 서명을 본 경우 다른 메시지의 해시와 서명을 재사용할 수 있습니다.
이것이 바로 오프체인이 아닌 스마트 계약에서 메시지를 해시하는 것이 매우 중요한 이유입니다.
이 공격이 실제로 실행되는 모습을 보려면 트위터에 게시한 CTF를 참조하세요.
1부: https://twitter.com/RareSkills_io/status/1650869999266037760
2부: https://twitter.com/RareSkills_io/status/1650897671543197701
https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611
서명을 사용하여 사용자를 식별해서는 안 됩니다. 가단성 때문에 고유하다고 가정할 수 없습니다. Msg.sender는 훨씬 더 강력한 고유성을 보장합니다.
여기에서 트위터에서 주최한 보안 훈련을 확인하세요. 코드베이스를 감사할 때 Solidity 페이지의 릴리스 발표 와 비교하여 Solidity 버전을 확인하여 버그가 있는지 확인하세요.
스마트 계약은 프록시 패턴(또는 드물게는 변형 패턴)으로 업그레이드할 수 있습니다. 스마트 계약은 변경되지 않은 상태로 유지되기 위해 임의의 스마트 계약 기능에 의존해서는 안 됩니다.
Solidity 기능 transfer 및 send를 사용하면 안 됩니다. 그들은 의도적으로 거래와 함께 전달되는 가스의 양을 2,300으로 제한하므로 대부분의 작업에 가스가 부족해집니다.
일반적으로 사용되는 gnosis 안전 다중 서명 지갑은 fallback 기능 에서 호출을 다른 주소로 전달하는 기능을 지원합니다. 누군가 전송 또는 보내기를 사용하여 Ether를 다중서명 지갑으로 보내는 경우 폴백 기능에 가스가 부족하여 전송이 실패할 수 있습니다. Ggnosis 안전 대체 기능의 스크린샷은 아래에 제공됩니다. 독자는 2300 가스를 모두 사용하기에 충분한 작업이 있음을 분명히 알 수 있습니다.
전송 및 보내기를 사용하는 계약과 상호 작용해야 하는 경우 저장 및 계약 액세스 작업의 가스 비용을 줄일 수 있는 이더리움 액세스 목록 트랜잭션 에 대한 기사를 참조하세요.
Solidity 0.8.0에는 오버플로 및 언더플로 보호 기능이 내장되어 있습니다. 따라서 검사되지 않은 블록이 존재하지 않거나 Yul의 하위 수준 코드가 사용되지 않는 한 오버플로 위험이 없습니다. 따라서 SafeMath 라이브러리는 추가 검사에 가스를 낭비하므로 사용해서는 안 됩니다.
block.timestamp가 채굴자가 조작할 수 있기 때문에 취약점 벡터라는 일부 문헌 문서가 있습니다. 이는 일반적으로 임의성의 소스로 타임스탬프를 사용하는 경우에 적용되며, 이전에 문서화된 대로 수행해서는 안 됩니다. 병합 후 Ethereum은 정확히 12초(또는 12초의 배수) 간격으로 타임스탬프를 업데이트합니다. 그러나 두 번째 수준 세분성으로 시간을 측정하는 것은 잘못된 패턴입니다. 1분 단위로 검증자가 블록 슬롯을 놓치고 블록 생성에 24초의 공백이 발생하면 상당한 오류 가능성이 있습니다.
코너 케이스는 쉽게 정의할 수 없지만 일단 충분히 본 후에는 직관을 개발하기 시작합니다. 코너 케이스는 누군가가 보상을 요구하려고 하지만 아무것도 걸지 않은 것과 같은 경우가 될 수 있습니다. 이것은 유효합니다. 우리는 그들에게 아무런 보상도 주어야 합니다. 마찬가지로, 우리는 일반적으로 보상을 균등하게 나누고 싶지만, 수신자가 단 한 명이고 기술적으로 분할이 발생하지 않는다면 어떻게 될까요?
이 예는 Akshay Srivastav의 트위터 스레드 에서 가져와 수정되었습니다.
권한 있는 주소 집합이 서명을 제공하는 경우 누군가가 권한 있는 작업을 수행할 수 있는 경우를 생각해 보세요.
contract VulnerableMultisigAuthorization { struct Authorization { bytes signature; address authorizer; bytes32 hashOfAction; // more fields } // more codef unction takeAction(Authorization[] calldata auths, bytes calldata action) public { // logic for avoiding replay attacks for (uint256 i; i < auths.length; ++i) { require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature"); require(authorizers[auths[i].authorizer], "address is not an authorizer"); } doTheAction(action) } }
서명 중 하나라도 유효하지 않거나 서명이 유효한 주소와 일치하지 않으면 되돌리기가 수행됩니다. 하지만 배열이 비어 있으면 어떻게 될까요? 이 경우 서명이 필요 없이 doTheAction까지 아래로 이동합니다.
contract ProportionalRewards { mapping(address => uint256) originalId; address[] stakers; function stake(uint256 id) public { nft.transferFrom(msg.sender, address(this), id); stakers.append(msg.sender); } function unstake(uint256 id) public { require(originalId[id] == msg.sender, "not the owner"); removeFromArray(msg.sender, stakers); sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length()); nft.transferFrom(address(this), msg.sender, id); } }
위의 코드는 모든 함수 구현을 표시하지는 않지만 함수가 이름에서 설명하는 대로 작동하더라도 여전히 버그가 있습니다. 그것을 발견할 수 있나요? 아래로 스크롤하기 전에 답변을 볼 수 없도록 공간을 제공하는 그림은 다음과 같습니다.
RemoveFromArray 및 sendRewards 함수의 순서가 잘못되었습니다. 스테이커 배열에 사용자가 한 명뿐인 경우 0으로 나누기 오류가 발생하고 사용자는 NFT를 출금할 수 없습니다. 게다가 작가가 의도한대로 보상이 나누어지지 않을 가능성도 있습니다. 원래 4명의 스테이커가 있었고 한 사람이 탈퇴했다면, 탈퇴 당시 배열 길이가 3이었기 때문에 그 사람은 보상의 3분의 1을 받게 됩니다.
일부 추정에 따르면 1억 달러 이상의 피해를 입힌 실제 사례를 사용해 보겠습니다. 컴파운드 프로토콜을 완전히 이해하지 못하더라도 걱정하지 마세요. 관련된 부분만 집중적으로 다루겠습니다. (또한 컴파운드 프로토콜은 DeFi 역사상 가장 중요하고 중요한 프로토콜 중 하나입니다. 우리는 DeFi 부트캠프 에서 이를 가르치므로 이것이 프로토콜에 대한 첫인상이라면 오해하지 마십시오.)
어쨌든, 컴파운드의 요점은 유휴 암호화폐를 사용할 수 있는 다른 거래자에게 빌려준 사용자에게 보상하는 것입니다. 대출 기관은 이자와 COMP 토큰으로 지급받습니다(차용인은 COMP 토큰 보상을 요구할 수 있지만 지금은 이에 대해 집중하지 않겠습니다).
복합 감사관은 복합 거버넌스에 의해 설정될 수 있는 구현에 대한 호출을 위임하는 프록시 계약입니다.
2021년 9월 30일 거버넌스 제안 62 에서 구현 계약은 취약점이 있는 구현 계약 으로 설정되었습니다. 활성화된 당일 일부 거래에서 제로 토큰 스테이킹에도 불구하고 COMP 보상을 요구하는 것이 트위터 에서 관찰되었습니다.
취약한 함수 distributionSupplierComp()
원본 코드는 다음과 같습니다.
/** * @notice Calculate COMP accrued by a supplier and possibly transfer it to them * @param cToken The market in which the supplier is interacting * @param supplier The address of the supplier to distribute COMP to */ function distributeSupplierComp(address cToken, address supplier) internal { // TODO: Don't distribute supplier COMP if the user is not in the supplier market. // This check should be as gas efficient as possible as distributeSupplierComp is called in many places. // - We really don't want to call an external contract as that's quite expensive. CompMarketState storage supplyState = compSupplyState[cToken]; uint supplyIndex = supplyState.index; uint supplierIndex = compSupplierIndex[cToken][supplier]; // Update supplier's index to the current index since we are distributing accrued COMP compSupplierIndex[cToken][supplier] = supplyIndex; if (supplierIndex == 0 && supplyIndex > compInitialIndex) { // Covers the case where users supplied tokens before the market's supply state index was set. // Rewards the user with COMP accrued from the start of when supplier rewards were first // set for the market. supplierIndex = compInitialIndex; } // Calculate change in the cumulative sum of the COMP per cToken accrued Double memory deltaIndex = Double({mantissa: sub_(supplyIndex, supplierIndex)}); uint supplierTokens = CToken(cToken).balanceOf(supplier); // Calculate COMP accrued: cTokenAmount * accruedPerCToken uint supplierDelta = mul_(supplierTokens, deltaIndex); uint supplierAccrued = add_(compAccrued[supplier], supplierDelta); compAccrued[supplier] = supplierAccrued; emit DistributedSupplierComp(CToken(cToken), supplier, supplierDelta, supplyIndex); }
아이러니하게도 버그는 TODO 주석에 있습니다. “사용자가 공급업체 시장에 있지 않은 경우 공급업체 COMP를 배포하지 마세요.” 그러나 코드에는 이에 대한 확인이 없습니다. 사용자가 지갑(CToken(cToken).balanceOf(supplier);)에 스테이킹 토큰을 보유하고 있는 한,
제안 64는 2021년 10월 9일에 버그를 수정했습니다.
이는 입력 유효성 검사 버그라고 주장될 수 있지만 사용자는 악의적인 내용을 제출하지 않았습니다. 누군가가 아무것도 스테이킹하지 않은 것에 대해 보상을 청구하려고 하면 올바른 계산은 0이 되어야 합니다. 틀림없이 이는 비즈니스 로직이나 코너 케이스 오류에 가깝습니다.
현실 세계에서 발생하는 DeFi 해킹은 위의 좋은 범주에 속하지 않는 경우가 많습니다.
패리티 지갑은 직접 사용하기 위한 것이 아닙니다. 스마트 계약 클론이 가리킬 참조 구현이었습니다. 원하는 경우 클론이 자체 파괴되도록 구현을 허용했지만 이를 위해서는 모든 지갑 소유자가 승인해야 했습니다.
// throw unless the contract is not yet initialized.modifier only_uninitialized { if (m_numOwners > 0) throw; _; } function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized { initDaylimit(_daylimit); initMultiowned(_owners, _required); }
지갑 소유자가 선언되었습니다
// kills the contract sending everything to `_to`.function kill(address _to) onlymanyowners(sha3(msg.data)) external { suicide(_to); }
일부 문헌에서는 이를 "보호되지 않은 자체 파괴", 즉 액세스 제어 실패로 설명하지만 이는 정확하지 않습니다. 문제는 구현 계약에서 initWallet 함수가 호출되지 않아 누군가가 직접 initWallet 함수를 호출하여 자신을 소유자로 만들 수 있다는 것이었습니다. 이는 그들에게 kill 함수를 호출할 수 있는 권한을 부여한 것입니다. 근본 원인은 구현이 초기화되지 않았기 때문입니다. 따라서 버그는 Solidity 코드의 결함이 아니라 배포 프로세스의 결함으로 인해 발생했습니다.
이번 해킹에서는 Solidity 코드가 악용되지 않았습니다. 대신, 공격자는 Cloudflare API 키를 획득하고 사용자 트랜잭션을 변경하여 공격자 주소로 인출을 유도하는 스크립트를 웹사이트 프런트엔드에 삽입했습니다. 이 기사 에서 자세한 내용을 읽어보세요.
앞에 0이 많이 있는 주소를 찾는 동기는 사용하기에 가스 효율성이 더 높다는 것입니다. 이더리움 거래에는 거래 데이터의 0바이트에 대해 4가스, 0이 아닌 바이트에 대해 16가스의 요금이 부과됩니다.
이처럼 Wintermute는 욕설 주소( writeup )를 사용했기 때문에 해킹을 당했습니다. 다음은 욕설 주소 생성기가 어떻게 손상되었는지에 대한 1inch의 글 입니다.
트러스트 지갑에는 이 기사( https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been- )에 설명된 유사한 취약점이 있었습니다. 도난당했습니다/ )
스마트 계약에는 개인 키가 없기 때문에 create2에서 솔트를 변경하여 발견된 앞에 0이 있는 스마트 계약에는 적용되지 않습니다.
타원 곡선 서명의 "r" 및 "s" 지점은 다음과 같이 생성됩니다.
r = k * G (mod N) s = k^-1 * (h + r * privateKey) (mod N)
G, r, s, h, an N은 모두 공개적으로 알려져 있습니다. "k"가 공개되면 "privateKey"가 유일하게 알려지지 않은 변수이며 해결될 수 있습니다. 이 때문에 지갑은 k를 완벽하게 무작위로 생성해야 하며 절대 재사용하지 않아야 합니다. 무작위성이 완벽하게 무작위적이지 않으면 k를 추론할 수 있습니다.
Java 라이브러리의 안전하지 않은 임의성 생성으로 인해 2013년에 많은 Android 비트코인 지갑이 취약해졌습니다. (비트코인은 이더리움과 동일한 서명 알고리즘을 사용합니다.)
이 목록에 있는 안티 패턴을 빠르게 인식하도록 훈련하면 더욱 효과적인 스마트 계약 프로그래머가 될 수 있지만 결과적으로 대부분의 스마트 계약 버그는 의도한 비즈니스 논리와 코드가 실제로 수행하는 작업 간의 불일치로 인해 발생합니다.
스마트 계약 단위 테스트는 틀림없이 스마트 계약을 위한 가장 기본적인 보호 장치이지만, 충격적인 수의 스마트 계약에는 단위 테스트가 부족하거나 테스트 적용 범위가 충분하지 않습니다.
그러나 단위 테스트는 계약의 "행복한 경로"(예상/설계된 동작)만 테스트하는 경향이 있습니다. 놀라운 사례를 테스트하려면 추가적인 테스트 방법론을 적용해야 합니다.
여기에 나온 일부 방법론에 익숙하지 않은 사람들을 위해 Cyfrin Audits의 Patrick Collins는 자신의 동영상 에서 상태 저장 및 상태 비저장 퍼징에 대한 유머러스한 소개를 제공합니다.
이러한 작업을 수행하기 위한 도구는 빠르게 널리 보급되고 사용하기 쉬워지고 있습니다.
일부 작성자는 다음 Repos에 이전 DeFi 해킹 목록을 작성했습니다.
Secureum은 보안을 연구하고 실습하는 데 널리 사용되었지만 저장소는 2년 동안 실질적으로 업데이트되지 않았습니다.
Solidity Riddles 저장소를 사용하여 Solidity 취약점 활용을 연습할 수 있습니다.
DamnVulnerableDeFi는 모든 개발자가 연습해야 할 고전적인 전쟁 게임입니다.
Capture The Ether 및 Ethernaut는 고전적이지만 일부 문제는 비현실적으로 쉽거나 오래된 Solidity 개념을 가르친다는 점을 명심하세요.
일부 평판이 좋은 크라우드소싱 보안 회사에는 연구할 수 있는 유용한 과거 감사 목록이 있습니다.
Solidity에 능숙하지 않다면 Ethereum 스마트 계약을 감사할 수 있는 방법이 없습니다.
스마트 계약 감사자가 되기 위해 업계에서 인정받는 인증은 없습니다. 누구나 견고성 감사자라고 주장하는 웹사이트와 소셜 미디어 프로필을 만들고 서비스 판매를 시작할 수 있으며, 많은 사람들이 그렇게 했습니다. 그러므로 고용하기 전에 주의를 기울이고 추천을 받으십시오.
스마트 계약 감사자가 되려면 버그 발견에 있어서 일반 Solidity 개발자보다 훨씬 더 뛰어나야 합니다. 따라서 감사자가 되기 위한 "로드맵"은 대부분의 사람들보다 더 나은 스마트 계약 버그 포착자가 될 때까지 수개월에 걸쳐 끊임 없이 신중하게 연습하는 것입니다.
동료보다 취약점을 식별하려는 의지가 부족하다면 고도로 훈련되고 동기가 부여된 범죄자보다 먼저 중요한 문제를 발견할 가능성이 거의 없습니다.
최근 스마트 계약 감사는 수익성이 높다는 인식으로 인해 일하기 좋은 분야로 인식되고 있습니다. 실제로 일부 버그 현상금 지급액은 100만 달러를 초과했지만 이는 극히 드문 예외이며 표준은 아닙니다.
Code4rena에는 감사 콘테스트에서 경쟁업체의 지불금에 대한 공개 리더보드가 있으며 이를 통해 성공률에 대한 일부 데이터를 얻을 수 있습니다.
보드에는 아직 1171개의 이름이 있습니다.
또한 Openzeppelin이 보안 연구 펠로우십(취업, 취업 전 심사 및 교육이 아닌) 지원서를 열었을 때 10명 미만의 후보자를 선택하기 위해 300개가 넘는 지원서를 받았는데 그 중 더 적은 수가 전체 자격을 얻었습니다. 시간 직업.
하버드보다 입학률이 낮네요.
스마트 계약 감사는 경쟁이 치열한 제로섬 게임입니다. 감사할 프로젝트도 너무 많고, 보안 예산도 너무 많고, 찾아야 할 버그도 너무 많습니다. 지금 보안을 공부하기 시작하면 의욕이 넘치는 수십 명의 개인과 팀이 여러분보다 앞서 나갈 것입니다. 대부분의 프로젝트에서는 테스트를 거치지 않은 신규 감사자보다는 평판이 좋은 감사자에게 기꺼이 프리미엄을 지불합니다.
이 문서에는 최소 20가지의 서로 다른 취약점 범주가 나열되어 있습니다. 각 항목을 마스터하는 데 일주일을 보냈다면(다소 낙관적임) 숙련된 감사자의 상식이 무엇인지 이제 막 이해하기 시작한 것입니다. 이 기사에서는 가스 최적화나 토큰경제학을 다루지 않았습니다. 둘 다 감사자가 이해해야 할 중요한 주제입니다. 계산해 보면 이것이 짧은 여정이 아니라는 것을 알 수 있습니다.
즉, 커뮤니티는 일반적으로 신규 이민자에게 친절하고 도움이 되며 팁과 요령이 풍부합니다. 그러나 스마트 계약 보안을 통해 경력을 쌓고자 하는 희망으로 이 기사를 읽는 사람들에게는 수익성 있는 경력을 얻을 가능성이 자신에게 유리하지 않다는 점을 분명히 이해하는 것이 중요합니다. 성공은 기본 결과가 아닙니다.
물론 그렇게 할 수 있으며, 꽤 많은 사람들이 Solidity를 전혀 모르고 감사 분야에서 수익성 있는 경력을 쌓았습니다. 로스쿨에 입학하고 변호사 시험에 합격하는 것보다 2년 안에 스마트 계약 감사관으로 취업하는 것이 더 쉽습니다. 확실히 다른 직업 선택에 비해 더 많은 장점이 있습니다.
그러나 그럼에도 불구하고 빠르게 진화하는 지식의 산을 마스터하고 버그를 발견하기 위한 직관을 연마하려면 엄청난 인내가 필요합니다.
이는 스마트 계약 보안을 배우는 것이 가치 있는 추구가 아니라는 말은 아닙니다. 그것은 절대적으로 그렇습니다. 그러나 달러 기호를 눈에 담고 현장에 접근하고 있다면 기대치를 확인하십시오.
알려진 안티패턴을 알고 있는 것이 중요합니다. 그러나 대부분의 실제 버그는 응용 프로그램마다 다릅니다. 두 가지 취약점 범주 중 하나를 식별하려면 지속적이고 신중한 연습이 필요합니다.
업계 최고의 견고성 교육을 통해 스마트 계약 보안과 더 많은 Ethereum 개발 주제를 알아보세요.
이 기사의 리드 이미지는 "컴퓨터를 보호하는 로봇"이라는 프롬프트를 통해 HackerNoon의AI 이미지 생성기 에 의해 생성되었습니다.