적대적 BAL의 조기 거부

이 기사는 기계로 번역되었습니다
원문 표시

적대적 BAL의 조기 거부

Ansgar , Marius , Francesco , Carl , Maria , Jochem 의 도움과 협조에 특별히 감사드립니다.

이더리움 개선 제안(EIP) -7928 (블록 수준 접근 목록, BAL)은 블록 실행 중에 접근하는 모든 상태 위치와 해당 상태 변경 사항을 빌더가 선언하도록 요구함으로써 블록 유효성 검사를 위한 병렬 실행 및 배치 I/O를 가능하게 합니다. 이를 통해 클라이언트는 상태를 미리 가져와 트랜잭션을 병렬로 실행할 수 있습니다.

하지만 현행 밸런서(BAL) 사양은 블록 검증 과정에서 불필요한 작업을 강제하는 데 악용될 수 있는 검증 비대칭성을 초래할 수 있습니다. 이는 실행 병렬화를 저해하지는 않지만, 배치 I/O 프리페칭의 이점을 무효화하는 동시에 상당한 대역폭과 실행 비용을 발생시킵니다.

요약: 유효하지 않은 블록이 유효한 최악의 경우 블록만큼 빠르게 무효화되지 않을 수 있습니다. 이로 인해 확장성이 저하됩니다. 클라이언트가 간단한 가스 예산 검사를 통해 이 문제를 완화할 수 있습니다. 이더리움 개선 제안(EIP) 에 대한 수정 제안서 초안은 여기에서 확인할 수 있습니다.

이 문제와 해결책을 이해하기 위해 먼저 BAL이 어떻게 작동하는지 살펴보겠습니다.

배경: 밸런서(BAL) 구조

밸런서(BAL) 계정별로 구성됩니다.

[address,storage_changes, # slot → [(block_access_index, post_value)] storage_reads, # [slot] balance_changes,nonce_changes,code_changes]

밸런서(BAL) 에는 두 가지 유형의 정보가 포함되어 있습니다.

  1. 트랜잭션 후 상태 차이쓰기 작업에는 block_access_index 주석이 달려 있으므로 어떤 트랜잭션이 상태의 특정 부분을 변경했는지 정확히 알 수 있습니다.

  2. 블록 이후 상태 위치읽기 ( storage_reads*_changes 없는 주소)는 트랜잭션 인덱스에 매핑되지 않습니다 . 블록 내 어딘가에서 해당 위치에 접근한다는 것은 알 수 있지만, 어떤 트랜잭션에 의해 접근되었는지는 알 수 없습니다.

읽기 작업에 트랜잭션 인덱스를 생략하는 이유는 무엇일까요? 이는 대역폭을 절약하기 위함입니다. 트랜잭션이 스토리지 슬롯을 읽고 쓰는 경우, `storage_changes` 테이블의 쓰기 항목이 읽기 항목을 포함하므로 중복이 발생하지 않습니다. 병렬 처리는 어떤 상태 위치에 접근하는지(언제 접근하는지는 중요하지 않음)만 알면 되기 때문에 트랜잭션 인덱스는 불필요한 오버헤드입니다.

이러한 비대칭성은 취약점을 이해하는 데 매우 중요합니다. 쓰기 작업은 조기에 검증할 수 있지만 읽기 작업은 그렇지 않습니다.

BAL을 사용한 블록 유효성 검사

블록 받은 후, 고객은 밸런서(BAL) 사용하여 다음을 수행합니다.

  • 거래 실행을 병렬화하고 , 전체 밸런서(BAL) 검증은 실행 이후로 연기합니다.
  • 디스크 지연 시간을 줄이기 위해 배치 I/O를 통해 필요한 상태를 미리 가져옵니다.
  • 실행과 병렬로 사후 상태 루트를 계산합니다.

BAL( 블록 유효성 검사) 이전에는 블록 유효성 검사가 간단했습니다. 최악의 경우는 사용 가능한 가스를 모두 소모하는 블록 이었습니다. 유효성 검사를 무시하더라도 상황이 더 악화될 일은 없었습니다. 클라이언트는 가스 한도에 도달하면 처리를 중단하고 블록 무효로 표시했기 때문입니다. 하지만 BAL이 도입되면서 상황이 달라졌습니다. 이제 유효한 블록의 최악의 실행 시간이 무효한 블록의 최악의 실행 시간보다 나빠지지 않도록 해야 합니다.

조기에 검증할 수 있는 것은 무엇일까요?

트랜잭션 실행 중에 트랜잭션 후 상태 차이를 검증할 수 있습니다. 쓰기 작업은 트랜잭션 인덱스에 매핑되므로 각 트랜잭션은 예상되는 상태 변화에 대해 독립적으로 유효성을 검사할 수 있습니다.

2026년 1월 30일 08:41:01 스크린샷
2026년 1월 30일 08:41:01 스크린샷 (941×427, 47.7KB)

조기에 검증할 수 없는 것

개별 트랜잭션 실행 중에는 블록 이후 상태 위치를 검증할 수 없습니다.

트랜잭션 x 읽은 스토리지 슬롯은 트랜잭션 y 쓸 수 있습니다. 최종 밸런서(BAL) 집계할 때 쓰기 작업은 읽기 작업을 소모합니다(슬롯은 storage_changes 아닌 storage_reads 에만 나타납니다). 슬롯이 최종적으로 읽기 작업으로 선언될지 여부는 모든 트랜잭션에 걸친 전체 쓰기 작업 집합에 따라 결정됩니다.

따라서 선언된 읽기 작업을 검증하려면 먼저 모든 트랜잭션을 실행해야 합니다.

사례 연구: Geth

예를 들어 Geth는 여러 워커 고루틴을 사용하여 트랜잭션을 실행하고 상태 차이를 병렬로 검증합니다. 상태 위치(읽기)의 경우, 모든 트랜잭션이 완료된 후 결과를 집계하는 ResultHandler 고루틴으로 검증이 연기됩니다.

2026년 1월 29일 18시 18분 41초의 스크린샷
2026년 1월 29일 18시 18분 41초 , 1014×682 해상도, 58.8KB 크기의 스크린샷

실제 적용 사례:

  • 작업자는 독립적으로 트랜잭션을 실행하고 상태 차이를 검증합니다.
  • 결과 스트림은 접근된 스토리지 슬롯을 수집하는 ResultHandler 로 전달됩니다.
  • 모든 트랜잭션이 완료된 후에야 핸들러는 선언된 읽기 횟수와 실제 접근 횟수를 비교하여 검증할 수 있습니다.

이러한 지연된 검증은 공격 기회를 만들어냅니다.

공격

이제 악의적인 블록 제작자가 어떤 점을 악용할 수 있는지 살펴보겠습니다.

설정

허락하다:

  • G G = 블록 가스 제한
  • G_{\mathrm{tx}} G t x = 거래당 최대 가스 제한( 이더리움 개선 제안(EIP)-7825 기준 ≈ 2^{24} 2 24 )
  • g_{\mathrm{sload}} = 2100 g s l o a d = 2100 = 냉간 시 최소 가스 비용 SLOAD

공격 건설

악의적인 제안자는 두 단계에 걸쳐 블록 구성합니다.

1단계: 가상 저장소 읽기 선언

storage_reads 에 저장 슬롯 집합 S S 를 선언합니다.

|S| = \left\lfloor \frac{G - G_{\mathrm{tx}}}{g_{\mathrm{sload}}} \right\rfloor
| S | = G G t x g s l o a d

이는 최대 가스 거래 하나를 예약한 후 남은 모든 가스를 SLOADs 에 사용하는 경우 블록 에 들어갈 수 있는 최대 고유 콜드 스토리지 읽기 수입니다.

2단계: 계산 전용 거래를 포함합니다.

실행 시 다음 조건을 만족하는 하나 이상의 거래를 포함하십시오.

  • 가스 사용량 ≈ G_{\mathrm{tx}} G t x
  • S S 의 어떤 슬롯에도 접근할 수 없습니다.
  • 순수 연산 (예: 산술 반복, 해싱)

결과적으로 생성된 밸런서(BAL) 구문적으로 유효하고 모든 가스 제한을 준수하지만, 선언된 저장소 읽기는 실행 중에 전혀 액세스되지 않습니다.

이것이 왜 문제가 되는가

클라이언트는 트랜잭션을 병렬로 실행하는 동안 미리 데이터를 가져오기 시작합니다. 개별 트랜잭션의 관점에서 볼 때, 모든 트랜잭션 실행이 완료되기 전에는 블록 을 무효화할 수 없습니다.

결과적으로, 유효하지 않은 블록 생성되어 유용한 I/O 프리페칭 기능이 완전히 비활성화되고, 블록 저장소 (밸런서(BAL) 크기가 수백 KiB( 블록 가스 한도 60M에서 0.6MiB)까지 커지며, 네트워크에 부담을 줍니다. 게다가 이러한 블록의 무효화는 처리 과정에서 너무 늦게 이루어집니다.

2026년 1월 30일 11시 7분 55초의 스크린샷
2026년 1월 30일 11시 7분 55초, 1228×421 해상도, 39.8KB 크기의 스크린샷

이는 사실상 유효 블록 수가 최악의 경우 허용할 수 있는 만큼 확장할 수 없지만, 무효 블록 수에 의해 제한된다는 것을 의미합니다.

왜 읽기 횟수를 과도하게 선언하지 않았을까요? 공격자 가 \left\lfloor \frac{G}{g_{\mathrm{sload}}} \right\rfloor G g s l o a d 라고 선언했다면요? 저장 슬롯을 모두 사용하는 경우(전체 가스 예산을 사용하는 경우), 클라이언트는 스토리지에 접근하지 않는 작업에 가스를 낭비하는 트랜잭션이 발생하면 즉시 블록 무효화할 수 있습니다. 하지만 최대 크기의 트랜잭션을 하나 이상 저장할 공간을 남겨두면, 어떤 단일 트랜잭션도 독립적으로 블록 조기에 무효화할 수 없습니다.

BALrog를 확인해 보세요. BALrog는 Engine API용 간단한 프록시로, 최악의 경우 BAL을 블록에 삽입하여 테스트에 유용하게 사용할 수 있습니다.

해결책: 가스 예산 타당성 검토

이번 공격은 두 가지 사실을 이용합니다.

  1. 개별 스레드는 다른 스레드에서 무슨 일이 일어나고 있는지 알지 못합니다.
  2. 주(州) 위치는 모든 거래가 완료된 후에만 검증 가능합니다.

하지만 우리는 핵심적인 제약 조건을 활용할 수 있습니다. 저장 슬롯에 대한 첫 번째 액세스 에는 최소한 콜드 SLOAD 비용(2100 가스, 또는 이더리움 개선 제안(EIP)-2930 액세스 목록의 경우 2000 가스, 최종 값은 이더리움 개선 제안(EIP)-7981 에 따라 달라짐)이 발생합니다.

불변량

실행 과정에서 주기적으로(예: 8건의 거래마다) 클라이언트는 다음을 확인합니다.

  • 가스가 얼마나 사용되었습니까?
  • 어떤 선언된 저장 슬롯에 접근했습니까?
  • 선언된 읽기 횟수가 몇 회 남았습니까?

그럼, 다음과 같이 합시다:

  • R_{\mathrm{declared}} R d e c l a r e d = 밸런서(BAL) 에 선언된 상태 읽기 횟수
  • R_{\mathrm{seen}} R s e e n = 이미 액세스한 번호
  • R_{\mathrm{remaining}} = R_{\mathrm{declared}} - R_{\mathrm{seen}} R_{ \ mathrm { remaining } } = R _ { \ mathrm { declared } } R _ { \ mathrm { seen } }
  • G_{\mathrm{ remaining } } G r e remaining = 남은 블록 블록

밸런서(BAL) 유효성을 위한 필수 조건은 다음과 같습니다.

G_{\mathrm{나머지}} \ge R_{\mathrm{나머지}} \cdot 2100
G re main R re main 2100

이 부등식이 성립하지 않으면 해당 블록 즉시 거부될 수 있습니다.

2026년 1월 30일 07:33-54의 스크린샷
2026년 1월 30일 07:33:54 화면 캡처, 1014×682 해상도, 65.1KB

이 사양에 대한 PR 초안은 여기에서 확인하세요.

이익

가스 예산 검사는 최대 가스 거래(약 1초에 약 1600만 가스 소모) 한 번만으로도 악성 블록을 조기에 거부합니다. 병렬 실행은 변경되지 않아 병렬화의 이점을 모두 유지합니다. 밸런서(BAL) 형식은 변경할 필요가 없으며, 유효 블록에 대한 배치 I/O는 완전히 복원됩니다. 구현 복잡성은 기존 결과 처리기에서 간단한 계산만 수행하면 되므로 최소화되며, 주기적인 산술 검사로 인한 성능 오버헤드는 무시할 수 있을 정도입니다.

고려된 대안들

또 다른 접근 방식은 읽기 전용 접근에 최초 접근 트랜잭션 인덱스를 추가하는 것입니다. 이렇게 하면 BAL이 자체적으로 설명 가능해지고 유효성 검사 로직이 단순화됩니다. 가스 예산 방식은 유사한 조기 거부 특성을 달성하지만, 밸런서(BAL) 에 추가 데이터를 저장하는 대신 실행 중에 추가적인 계산을 수행합니다. 인덱스를 읽기에 매핑하면 밸런서(BAL) 에 평균 4%의 추가 (압축된) 데이터가 추가됩니다.

또 다른 대안은 밸런서(BAL) 에 있는 주 위치들을 블록 내에서 접근이 발생하는 시간 순서대로 정렬하는 것입니다. 그러나 이렇게 하면 복잡성이 추가될 뿐만 아니라 밸런서(BAL) 에 대한 증거 제시가 더욱 어려워집니다.

가스 예산 타당성 검사는 간단하고 효과적인 완화책을 제공합니다. 남은 가스가 남은 선언된 읽기 작업을 처리할 수 있는지 확인함으로써 클라이언트는 밸런서(BAL) 형식을 변경하거나 병렬 처리 이점을 희생하지 않고도 악성 블록을 조기에 거부할 수 있습니다.

샘플 결과 처리 로직

MIN_GAS_PER_READ = 2100 # cold SLOAD cost CHECK_EVERY_N_TXS = 8 def result_handler ( block, bal, tx_results_channel ): # Count expected storage reads from the BAL (once, upfront) expected_reads = sum ( len (acc.storage_reads) for acc in bal.accounts)accessed_slots = set ()total_gas_used = 0 for i, result in enumerate (tx_results_channel, start= 1 ): # Merge this transaction's accessed slots accessed_slots.update(result.accessed_slots)total_gas_used += result.gas_used # Periodic feasibility check if i % CHECK_EVERY_N_TXS == 0 :remaining_gas = block.gas_limit - total_gas_usedunaccessed_reads = expected_reads - len (accessed_slots)min_gas_needed = unaccessed_reads * MIN_GAS_PER_READ if min_gas_needed > remaining_gas: raise Exception( "BAL infeasible: " f" {unaccessed_reads} reads need {min_gas_needed} gas, " f"only {remaining_gas} left" )

자원


출처
면책조항: 상기 내용은 작자의 개인적인 의견입니다. 따라서 이는 Followin의 입장과 무관하며 Followin과 관련된 어떠한 투자 제안도 구성하지 않습니다.
라이크
84
즐겨찾기에 추가
14
코멘트