
Solidity 컴파일러 취약점 분석: ABI 재인코딩의 결함
개요
본 문서는 Solidity 컴파일러(0.5.8 ≤ 버전 < 0.8.16)의 ABI 재인코딩 과정에서 고정 길이 uint 및 bytes32 타입 배열을 잘못 처리함으로써 발생하는 취약점에 대해 소스 코드 수준에서 상세히 분석하고, 관련 해결 방안과 우회 조치를 제시합니다.
취약점 세부 정보
ABI 인코딩 형식은 사용자 또는 컨트랙트가 컨트랙트의 함수를 호출할 때 매개변수를 전달하기 위한 표준 인코딩 방식입니다. 자세한 내용은 Solidity 공식 문서의 ABI 인코딩 설명을 참조하세요.
컨트랙트 개발 과정에서 사용자나 다른 컨트랙트로부터 전달된 calldata에서 필요한 데이터를 추출하여 이를 전달하거나 emit하는 등의 작업을 수행할 수 있습니다. EVM 가상 머신의 모든 opcode는 memory, stack, storage 기반으로 동작하므로, Solidity에서는 데이터에 대해 ABI 인코딩을 수행할 경우, calldata의 데이터를 새로운 순서에 따라 ABI 형식으로 다시 인코딩하여 memory에 저장하게 됩니다.
이러한 과정 자체에는 큰 논리적 문제가 없으나, Solidity의 cleanup 메커니즘과 결합될 때, 컴파일러 코드 자체의 누락으로 인해 취약점이 발생합니다.
ABI 인코딩 규칙에 따르면, 함수 선택자를 제외한 후 ABI 인코딩 데이터는 head와 tail 두 부분으로 나뉩니다. 데이터 형식이 고정 길이 uint 또는 bytes32 배열일 경우, 해당 데이터는 모두 head 부분에 저장됩니다. 한편 Solidity의 memory cleanup 메커니즘은 현재 인덱스의 메모리를 사용한 후 다음 인덱스의 메모리를 0으로 초기화하여, 이후 메모리 사용 시 오염된 데이터의 영향을 방지합니다. 또한 Solidity는 일련의 매개변수를 ABI 인코딩할 때 **왼쪽에서 오른쪽 순서로 인코딩을 수행합니다**!
후속 취약점 원리 분석을 용이하게 하기 위해 아래와 같은 형태의 컨트랙트 코드를 살펴보겠습니다:
contract Eocene {
event VerifyABI(bytes[], uint[2]);
function verifyABI(bytes[] calldata a, uint[2] calldata b) public {
emit VerifyABI(a, b); // 이벤트 데이터는 ABI 형식으로 인코딩되어 블록체인에 저장됨
}
}
컨트랙트 Eocene의 verifyABI 함수는 단순히 함수 매개변수인 가변 길이 bytes[] a와 고정 길이 uint[2] b를 emit하는 역할만 합니다.
여기서 주목해야 할 점은 event도 ABI 인코딩을 트리거한다는 것입니다. 즉, 매개변수 a와 b는 ABI 형식으로 인코딩된 후 체인에 저장됩니다.
v0.8.14 버전의 Solidity를 사용해 컨트랙트를 컴파일하고 Remix를 통해 배포한 후, verifyABI(['0xaaaaaa','0xbbbbbb'], [0x11111, 0x22222])를 전달해 봅시다.
먼저 verifyABI(['0xaaaaaa','0xbbbbbb'], [0x11111, 0x22222])의 올바른 인코딩 형식을 살펴보겠습니다:
0x52cd1a9c // bytes4(sha3("verify(bytes[], uint[2])"))
0000000000000000000000000000000000000000000000000000000000000060 // a의 인덱스
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000002 // a의 길이
0000000000000000000000000000000000000000000000000000000000000040 // a[0]의 인덱스
0000000000000000000000000000000000000000000000000000000000000080 // a[1]의 인덱스
0000000000000000000000000000000000000000000000000000000000000003 // a[0]의 길이
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // a[1]의 길이
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
Solidity 컴파일러가 정상 작동한다면 매개변수 a, b가 event를 통해 체인에 기록될 때 데이터 형식은 우리가 전송한 것과 동일해야 합니다. 실제로 컨트랙트를 호출한 후 체인의 로그를 확인해 봅시다. 직접 비교하고 싶다면 이 TX를 참고하세요.
성공적으로 호출된 후 컨트랙트의 event 기록은 다음과 같습니다:
!! 충격! b[1] 바로 다음에 위치한 a의 길이 값이 잘못 삭제되었습니다!!
0000000000000000000000000000000000000000000000000000000000000060 // a의 인덱스
0000000000000000000000000000000000000000000000000000000000011111 // b[0]
0000000000000000000000000000000000000000000000000000000000022222 // b[1]
0000000000000000000000000000000000000000000000000000000000000000 // a의 길이?? 왜 0이 되었나?
0000000000000000000000000000000000000000000000000000000000000040 // a[0]의 인덱스
0000000000000000000000000000000000000000000000000000000000000080 // a[1]의 인덱스
0000000000000000000000000000000000000000000000000000000000000003 // a[0]의 길이
aaaaaa0000000000000000000000000000000000000000000000000000000000 // a[0]
0000000000000000000000000000000000000000000000000000000000000003 // a[1]의 길이
bbbbbb0000000000000000000000000000000000000000000000000000000000 // a[1]
왜 이런 일이 발생했을까요?
앞서 언급했듯이, Solidity가 일련의 매개변수에 대해 ABI 인코딩을 수행할 때 인코딩 순서는 왼쪽에서 오른쪽입니다. 매개변수 a와 b에 대한 구체적인 인코딩 로직은 다음과 같습니다:
-
Solidity는 먼저 a를 ABI 인코딩합니다. 인코딩 규칙에 따라 a의 인덱스는 head에 배치되고, a의 요소 길이 및 실제 값은 tail에 저장됩니다.
-
b 데이터를 처리할 때, b는 uint[2] 형식이므로 실제 값은 head 부분에 저장됩니다. 그러나 Solidity의 cleanup 메커니즘 때문에 b[1] 값을 메모리에 저장한 후, 그 다음 메모리 주소(즉, a의 요소 길이를 저장할 위치)를 0으로 초기화합니다.
-
ABI 인코딩 작업이 종료되었지만, 잘못 인코딩된 데이터가 체인에 저장되며 SOL-2022-6 취약점이 발생합니다.
소스 코드 수준에서도 명백한 오류 로직이 존재합니다. Calldata에서 고정 길이의 bytes32 또는 uint 배열 데이터를 memory로 가져올 때, Solidity는 항상 데이터 복사 후 다음 메모리 인덱스를 0으로 설정합니다. 여기에 ABI 인코딩이 head와 tail로 나뉘고, 인코딩 순서가 왼쪽에서 오른쪽이라는 점이 결합되어 취약점이 발생합니다.
구체적인 취약점이 있는 Solidity 컴파일러 코드는 다음과 같습니다:
소스 데이터의 저장 위치가 Calldata이며, 소스 데이터 타입이 ByteArray, String이거나 배열 요소 타입이 uint 또는 bytes32일 경우 ABIFunctions::abiEncodingFunctionCalldataArrayWithoutCleanup() 함수로 진입합니다.

진입 후 먼저 fromArrayType.isDynamicallySized()를 통해 소스 데이터가 고정 길이 배열인지 판단하며, 오직 고정 길이 배열만이 취약점 유발 조건을 만족합니다.
isByteArrayOrString()의 판별 결과를 YulUtilFunctions::copyToMemoryFunction()에 전달하여, calldatacopy 연산 후 다음 인덱스 위치에 대해 cleanup을 수행할지 결정합니다.
위 조건들이 결합됨으로써, 오직 Calldata에 위치한 고정 길이의 uint 또는 bytes32 배열이 memory로 복사될 때만 취약점이 발생합니다. 이것이 바로 취약점 유발 조건의 근원입니다.

ABI가 매개변수를 인코딩할 때 항상 왼쪽에서 오른쪽 순서라는 점을 고려하면, 취약점 이용 조건을 이해하기 위해 반드시 다음 사항을 알아야 합니다: 고정 길이의 uint 및 bytes32 배열 앞에 ABI 인코딩의 tail 부분에 저장되는 가변 길이 데이터가 존재해야 하며, 고정 길이 배열은 인코딩 대상 매개변수 중 마지막 위치에 있어야 합니다.
이유는 명확합니다. 고정 길이 데이터가 마지막 매개변수가 아니라면 다음 매개변수가 해당 위치를 덮어쓰므로 다음 메모리 위치를 0으로 초기화해도 아무런 영향이 없습니다. 또한 고정 길이 데이터 앞에 tail에 저장되는 데이터가 없다면, 비록 다음 메모리 위치가 0으로 설정되더라도 해당 위치가 ABI 인코딩에 사용되지 않으므로 문제되지 않습니다.
또한, 모든 암시적 또는 명시적 ABI 작업 및 해당 형식을 갖춘 모든 튜플(Tuple, 데이터 그룹)은 이 취약점의 영향을 받습니다.
영향을 받는 구체적인 작업은 다음과 같습니다:
-
event
-
error
-
abi.encode*
-
returns // 함수 반환값
-
struct // 사용자 정의 구조체
-
모든 external call
해결 방안
-
컨트랙트 코드에서 위와 같은 영향을 받는 작업을 사용할 경우, 마지막 매개변수가 고정 길이의 uint 또는 bytes32 배열이 되지 않도록 보장합니다.
-
이 취약점의 영향을 받지 않는 Solidity 컴파일러 버전을 사용합니다.
-
전문 보안 담당자의 도움을 받아 컨트랙트에 대한 전문적인 보안 감사를 수행합니다.
TechFlow 공식 커뮤니티에 오신 것을 환영합니다
Telegram 구독 그룹:https://t.me/TechFlowDaily
트위터 공식 계정:https://x.com/TechFlowPost
트위터 영어 계정:https://x.com/BlockFlow_News














