포인터 전달 인터페이스
포인터 전달 인터페이스
SQLite 3.20.0 (2017-08-01)에 "포인터 전달"을 위한 세 개의 _pointer() 인터페이스가 추가됐어요. 이 글에서는 그 인터페이스들이 왜 생겼고, 어떤 문제를 해결하는지 설명해 드릴게요.
본문
1. 개요 (Overview)
SQLite 3.20.0 (2017-08-01)에 새 _pointer() 인터페이스 세 개가 추가됐어요:
이 새 인터페이스들의 목적, 왜 소개되었는지, 어떤 문제를 해결하는지에 대해 메일링 리스트에서 질문과 혼란이 곧 일어났어요. 이 수필은 그 질문들에 답하고 혼란을 해소하려고 해요.
2. SQLite 에서 포인터 전달의 간단한 역사
SQLite 확장이 하위 구성 요소 간에, 또는 확장과 애플리케이션 간에 SQL이 아닌 값을 전달하는 것이 때때로 편리해요. 몇 가지 예:
- FTS3 확장에서 (전문 검색을 수행하는) MATCH 연산자는 일치 항목의 세부사항을 snippet(), offsets(), matchinfo() 함수에 전달해서, 그 함수들이 일치 세부사항을 유용한 출력으로 변환할 수 있게 해야 해요.
- 애플리케이션이 새 토크나이저 같은 새 확장을 FTS5에 추가하려면, "fts5_api" 객체에 대한 포인터가 필요해요.
- CARRAY 확장에서 애플리케이션은 확장이 구현하는 테이블-값 함수의 데이터를 담고 있는 C 언어 배열의 위치를 확장에 알려줘야 해요.
이 정보를 전달하는 전통적인 방법은 C 언어 포인터를 BLOB나 64-bit 정수로 변환한 다음, sqlite3_bind_blob(), sqlite3_result_blob(), sqlite3_value_blob() 같은 일반적인 인터페이스나 그 정수 버전들로 그 BLOB나 정수를 SQLite를 통해 옮기는 것이었어요.
2.1. 위협 수준 높이기
포인터를 정수나 BLOB인 것처럼 주고받는 것은 쉽고 효과적이며, 애플리케이션 구성 요소들이 모두 서로 우호적인 환경에서는 잘 동작해요. 하지만 포인터를 정수와 BLOB로 전달하면, 적대적인 SQL 텍스트가 잘못된 포인터를 위조해 해를 끼칠 수 있게 돼요.
예를 들어 snippet() 함수의 첫 번째 인자는 FTS3 테이블의 특수 컬럼이어야 해요. 그 컬럼은 현재 전문 검색 일치에 대한 정보를 담고 있는 fts3cursor 객체에 대한 포인터를 담고 있죠. 그 포인터는 이전에는 BLOB로 전달됐어요. 예를 들어 FTS3 테이블 이름이 "t1"이고 "cx"라는 컬럼이 있다면, 다음과 같이 쓸 수 있어요:
SELECT snippet(t1) FROM t1 WHERE cx MATCH $pattern;
하지만 해커가 임의의 SQL을 실행할 수 있다면, 다음과 같은 약간 다른 쿼리를 실행할 수도 있어요:
SELECT hex(t1) FROM t1 WHERE cx MATCH $pattern;
(이전 SQLite 버전에서) 포인터가 t1 테이블의 t1 컬럼에 BLOB로 전달되기 때문에, 그런 쿼리는 포인터의 값을 16진수로 보여줬을 거예요. 그러면 공격자는 그 포인터를 수정해서, snippet() 함수가 원래 동작해야 했던 fts3cursor 객체 대신 애플리케이션 주소 공간의 다른 부분의 메모리를 수정하게 만들려고 시도할 수 있었어요:
SELECT snippet(x'6092310100000000') FROM t1 WHERE cx MATCH $pattern;
역사적으로 이것은 위협으로 간주되지 않았어요. 적대적 행위자가 애플리케이션에 임의의 SQL 텍스트를 주입할 수 있다면, 그 행위자는 이미 애플리케이션을 완전히 제어하고 있으므로, 적대적 행위자가 포인터를 위조하게 허용해도 그 행위자에게 새로운 능력을 주지 않는다는 논거였죠.
대부분의 경우 잠재적 공격자가 임의의 SQL을 주입할 방법이 없다는 것이 사실이므로, 대부분의 SQLite 사용은 위의 공격에 면역이에요. 하지만 주목할 만한 예외가 몇 가지 있어요. 즉:
- webkit의 WebSQL 인터페이스는 Chrome과 Safari에서 어떤 웹페이지든 브라우저에서 임의의 SQL을 실행하게 했어요. 그 임의의 SQL은 악용되어도 해를 끼칠 수 없는 샌드박스 안에서 실행되도록 되어 있었지만, 그 샌드박스는 사람들이 생각한 것보다 덜 안전한 것으로 드러났어요. 2017년 봄, 한 해커 팀이 긴 일련의 익스플로잇으로 iMac을 루팅할 수 있었는데, 그중 하나는 Safari 안에서 WebSQL 인터페이스를 통해 실행되는 SQLite 데이터베이스의 snippet() FTS3 함수에 BLOB 값으로 전달된 포인터를 손상시키는 것이었어요.
- Android에는 인터넷의 수상한 구석에서 다운로드한 신뢰할 수 없는 앱이 전달하는 임의의 SQL을 맹목적으로 실행하는 서비스가 많다고 들었어요. Android 서비스는 검증되지 않은 소스의 SQL을 실행하는 데 더 방어적이어야 한다고 여겨져요. 이 저자는 반대의 구체적인 예를 가지고 있지 않지만, 그것들이 존재한다는 소문을 들었어요. 모든 Android 서비스가 더 조심하고 실행하는 모든 SQL을 제대로 검증한다 해도, 안전한지 확인하기 위해 모두 감사하는 것은 어려울 거예요. 그래서 보안에 신경 쓰는 사람들은 임의의 SQL 텍스트를 전달해서 익스플로잇이 가능하지 않도록 하는 것을 열망해요.
- Fossil 버전 관리 시스템(SQLite 개발을 지원하기 위해 설계·작성됨)은 약하게 신뢰되는 사용자가 문제(trouble-ticket) 보고서를 생성하기 위해 임의의 SQL을 입력하게 해요. 그 SQL은 sqlite3_set_authorizer() 인터페이스로 정화되며, 지금까지 발견된 익스플로잇은 없어요. 하지만 이것은 잠재적으로 적대적인 행위자가 임의의 SQL을 시스템에 주입할 수 있는 예시예요.
2.2. 위조 포인터 방지
포인터 전달의 보안 공백을 막기 위한 첫 시도는 포인터 값이 위조되는 것을 막는 것이었어요. 이는 sqlite3_result_subtype()으로 보내는 쪽이 각 포인터에 하위 타입(subtype)을 붙이고, 받는 쪽이 sqlite3_value_subtype()으로 그 하위 타입을 검증해 잘못된 하위 타입의 포인터를 거부하게 해서 달성됐어요. 순수 SQL로는 결과에 하위 타입을 붙일 방법이 없으므로, 이것은 SQL로 포인터를 위조하는 것을 막아요. 포인터를 보내는 유일한 방법은 C 코드를 사용하는 것이에요. 공격자가 하위 타입을 설정할 수 있다면, 그는 SQLite의 도움 없이도 포인터를 위조할 수 있는 사람이에요.
하위 타입으로 유효한 포인터를 식별하는 것은 WebSQL 익스플로잇을 막았어요. 하지만 그것은 불완전한 해결책인 것으로 드러났어요.
2.3. 포인터 누출 (Pointer Leaks)
포인터에 하위 타입을 사용하는 것은 순수 SQL을 사용한 포인터 위조를 막았어요. 하지만 하위 타입은 공격자가 포인터의 값을 읽는 것을 막지 못해요. 다시 말해, 포인터 값에 하위 타입을 사용하는 것은 다음과 같은 SQL 문을 사용한 공격을 막아요:
SELECT snippet(x'6092310100000000') FROM t1 WHERE cx MATCH $pattern;
snippet()에 대한 BLOB 인자는 올바른 하위 타입을 갖지 않으므로, snippet 함수는 그것을 무시하고, 어떤 데이터 구조도 변경하지 않으며, 무해하게 NULL을 반환해요.
하지만 하위 타입을 사용하는 것은 다음과 같은 SQL 코드로 포인터 값을 읽는 것을 막지 못해요:
SELECT hex(t1) FROM t1 WHERE cx MATCH $pattern;
그것이 무슨 해가 될까 하고 물을 수 있겠죠? SQLite 개발자들(이 저자 포함)도 같은 생각을 했어요. 하지만 그때 보안 연구원들이, 포인터에 대한 지식이 공격자가 주소 공간 무작위화 방어를 우회하는 데 도움이 될 수 있다고 지적했어요. 이것을 "포인터 누출(pointer leak)"이라고 불러요. 포인터 누출 자체는 취약점이 아니지만, 공격자가 다른 취약점을 효과적으로 익스플로잇하는 데 도움이 될 수 있어요.
3. 새 포인터 전달 인터페이스
확장 구성 요소가 안전하게 그리고 포인터 누출 없이 서로 비공개 정보를 전달하려면 새 인터페이스가 필요해요:
- sqlite3_bind_pointer(S, I, P, T, D) → 타입 T의 포인터 P를 준비된 문 S의 I번째 매개변수에 바인딩한다. D는 P를 위한 선택적 소멸자 함수.
- sqlite3_result_pointer(C, P, T, D) → 타입 T의 포인터 P를 함수 C의 인자로 반환한다. D는 P를 위한 선택적 소멸자 함수.
- sqlite3_value_pointer(V, T) → 값 V와 연관된 타입 T의 포인터를 반환하거나, V에 연관된 포인터가 없거나 V의 포인터가 T와 다른 타입이면 NULL을 반환한다.
SQL에게 sqlite3_bind_pointer()와 sqlite3_result_pointer()가 만든 값은 NULL과 구별할 수 없어요. hex() 함수로 포인터의 값을 읽으려고 시도하는 SQL 문은 SQL NULL 답을 얻을 거예요. 값에 연관된 포인터가 있는지 여부를 알아내는 유일한 방법은 적절한 타입 문자열 T와 함께 sqlite3_value_pointer() 인터페이스를 사용하는 것이에요.
sqlite3_value_pointer()가 읽는 포인터 값은 순수 SQL로 생성할 수 없어요. 따라서 SQL이 포인터를 위조하는 것은 불가능해요.
sqlite3_bind_pointer()와 sqlite3_result_pointer()가 생성하는 포인터 값은 순수 SQL로 읽을 수 없어요. 따라서 SQL이 포인터의 값을 누출하는 것은 불가능해요.
이런 식으로 새 포인터 전달 인터페이스는 SQLite에서 한 확장에서 다른 확장으로 포인터 값을 전달하는 것과 연관된 모든 보안 문제를 해결하는 것처럼 보여요.
3.1. 포인터 타입 (Pointer Types)
sqlite3_bind_pointer(), sqlite3_result_pointer(), sqlite3_value_pointer()의 마지막 매개변수의 "포인터 타입"은 한 확장을 위한 포인터가 다른 확장으로 리디렉션되는 것을 막는 데 사용돼요. 예를 들어 포인터 타입을 사용하지 않으면, 공격자는 FTS3과 CARRAY 확장이 모두 포함된 시스템에서 다음과 같은 SQL로 여전히 포인터 정보에 접근할 수 있을 거예요:
SELECT ca.value FROM t1, carray(t1,10) AS ca WHERE cx MATCH $pattern
위 문에서 MATCH 연산자가 생성한 FTS3 커서 포인터는 의도된 수신자 snippet() 대신 carray() 테이블-값 함수로 보내져요. carray() 함수는 그 포인터를 정수 배열에 대한 포인터로 취급하고 각 정수를 하나씩 반환해서, FTS3 커서 객체의 내용을 누출해요. FTS3 커서 객체는 다른 객체에 대한 포인터들을 담고 있으므로, 위 문은 포인터 누출이 될 거예요.
단, 포인터 타입 덕분에 위 문은 동작하지 않아요. MATCH 연산자가 생성한 포인터는 "fts3cursor" 타입이지만, carray() 함수는 "carray" 타입의 포인터를 받을 것으로 기대해요. sqlite3_result_pointer()의 포인터 타입이 sqlite3_value_pointer() 호출의 포인터 타입과 일치하지 않으므로, sqlite3_value_pointer()는 carray()에서 NULL을 반환하고, 이로써 CARRAY 확장에게 잘못된 포인터가 전달되었음을 알려요.
3.1.1. 포인터 타입은 정적 문자열
포인터 타입은 정적 문자열인데, 이상적으로는 다른 함수에서 전달된 매개변수가 아니라 SQLite API 호출에 직접 내장된 문자열 리터럴이어야 해요. 포인터 타입으로 정수 값을 사용하는 것도 고려되었지만, 정적 문자열은 훨씬 더 큰 이름 공간을 제공해서 관련 없는 확장 사이의 우연한 타입 이름 충돌 가능성을 줄여 줘요.
"정적 문자열(static string)"이란 프로그램 수명 동안 고정되고 변하지 않는 0-종료 바이트 배열을 의미해요. 다시 말해 포인터 타입 문자열은 문자열 상수여야 해요. 반면 "동적 문자열(dynamic string)"은 힙에서 할당된 메모리에 보관되어 메모리 누수를 피하려면 해제해야 하는 0-종료 바이트 배열이에요. 포인터 타입 문자열로 동적 문자열을 사용하지 마세요.
여러 평론가들이 포인터 타입에 동적 문자열을 사용하고, SQLite가 타입 문자열의 소유권을 가져가서 사용이 끝나면 자동으로 해제하게 하고 싶다는 바람을 표현했어요. 그 설계는 다음 이유로 거부됐어요:
- 포인터 타입은 유연하고 동적이도록 의도된 것이 아니에요. 포인터 타입은 설계-시간 상수로 의도된 것이에요. 애플리케이션은 런타임에 포인터 타입 문자열을 합성하면 안 돼요. 동적 포인터 타입 문자열에 대한 지원을 제공하면, 개발자들이 런타임에 합성된 포인터 타입 문자열을 만들어 포인터 전달 인터페이스를 오용하게 될 거예요. 포인터 타입 문자열을 정적으로 요구하면 개발자들이 설계-시간에 고정된 포인터 타입 이름을 선택하고 그 이름을 상수 문자열로 인코딩하는 올바른 일을 하도록 장려해요.
- SQLite에서 SQL 수준의 모든 문자열 값은 동적 문자열이에요. 타입 문자열을 정적이도록 요구하면 임의의 타입의 포인터를 합성할 수 있는 애플리케이션 정의 SQL 함수를 만드는 것을 어렵게 만들어요. 우리는 사용자가 그런 SQL 함수를 만드는 것을 원하지 않아요. 그런 함수는 시스템의 보안을 위협할 것이기 때문이에요. 따라서 정적 문자열을 요구하는 것은 잘못 설계된 SQL 함수에 대해 포인터 전달 인터페이스의 무결성을 방어하는 데 도움이 돼요. 정적 문자열 요구사항은 완벽한 방어가 아니에요. 정교한 프로그래머는 그 주위를 코드화할 수 있고, 초보 프로그래머는 그냥 메모리 누수를 감수할 수 있으니까요. 하지만 포인터 타입 문자열이 정적이어야 한다고 명시함으로써, 포인터 타입에 동적 문자열을 사용할지도 모르는 개발자들이 문제를 더 신중하게 생각하고 보안 문제를 도입하지 않도록 장려하기를 바라요.
- SQLite가 타입 문자열의 소유권을 가지면, 포인터 전달 인터페이스를 사용하지 않는 애플리케이션을 포함해 모든 애플리케이션에 성능 비용이 부과돼요. SQLite는 값을 sqlite3_value 객체의 인스턴스로 전달해요. 그 객체에는 소멸자가 있는데, sqlite3_value 객체가 거의 모든 것에 사용되기 때문에 그 소멸자는 빈번하게 호출돼요. 소멸자가 해제할 포인터 타입 문자열이 있는지 확인해야 한다면, 소멸자에 대한 각 호출에서 소모해야 하는 몇 개의 추가 CPU 사이클이에요. 그 사이클들은 쌓여요. 포인터 전달이 흔히 사용되는 프로그래밍 패러다임이라면 추가 CPU 사이클의 비용을 감수할 의향이 있지만, 포인터 전달은 드물고, 포인터 전달을 사용하지 않는 수십억, 수백억의 애플리케이션에 그것을 사용하는 몇 개 애플리케이션의 편의를 위해 런타임 비용을 부과하는 것은 현명하지 않은 것 같아요.
애플리케이션에 동적 포인터 타입 문자열이 필요하다고 느낀다면, 그것은 포인터 전달 인터페이스를 오용하고 있다는 강한 신호예요. 의도한 사용이 안전하지 않을 수 있어요. 설계를 다시 생각해 주세요. 정말로 포인터를 SQL을 통해 전달해야 하는지 먼저 결정해 보세요. 아니면 이 글에서 설명하는 포인터 전달 인터페이스 외의 다른 메커니즘을 찾아보세요.
3.2. 소멸자 함수 (Destructor Functions)
sqlite3_bind_pointer()과 sqlite3_result_pointer() 루틴의 마지막 매개변수는 SQLite가 그것을 다 사용한 후 P 포인터를 처리하는 데 사용되는 프로시저에 대한 포인터예요. 이 포인터는 NULL이 될 수 있고, 그 경우 아무 소멸자도 호출되지 않아요.
D 매개변수가 NULL이 아니면, 포인터의 소유권이 SQLite로 이전된다는 뜻이에요. SQLite는 포인터 사용을 마쳤을 때 포인터와 연관된 리소스를 해제할 책임을 질 거예요. D 매개변수가 NULL이면, 포인터의 소유권이 호출자에게 남아 있고 호출자가 포인터를 처리할 책임이 있어요.
소멸자 함수 D는 타입 문자열 T가 아니라 포인터 값 P를 위한 것이라는 점에 주목하세요. 타입 문자열 T는 무한한 수명을 가진 정적 문자열이어야 해요.
sqlite3_bind_pointer()이나 sqlite3_result_pointer()에 non-NULL D 매개변수를 제공해 포인터의 소유권이 SQLite로 전달되면, 객체가 파괴될 때까지 소유권은 SQLite에 남아 있어요. 소유권을 SQLite 밖으로 다시 애플리케이션으로 이전할 방법은 없어요.
4. 포인터 값 사용에 대한 제한
sqlite3_bind_pointer(), sqlite3_result_pointer(), sqlite3_value_pointer() 인터페이스로 SQL NULL 값에 편승하는 포인터는 일시적이고 덧없는 것이에요. 포인터는 결코 데이터베이스에 쓰여지지 않아요. 포인터는 정렬(sorting)을 견디지 못해요. 후자의 사실이 sqlite3_column_pointer() 인터페이스가 없는 이유예요. 쿼리 플래너가 쿼리에서 값을 반환하기 전에 정렬 연산을 삽입할지 여부를 예측하는 것이 불가능하기 때문에, sqlite3_bind_pointer()나 sqlite3_result_pointer()가 쿼리에 넣은 포인터 값이 결과 집합까지 살아남을지 알 방법이 없기 때문이에요.
포인터 값은 생산자에서 소비자로 직접 흘러야 하며, 중간 연산자나 함수가 없어야 해요. 포인터 값의 어떤 변환이든 포인터를 파괴하고 그 값을 보통의 SQL NULL로 변환해요.
5. 요약 (Summary)
이 수필의 핵심 요점:
- 인터넷은 점점 더 적대적인 곳이에요. 요즘 개발자들은 공격자가 애플리케이션에서 임의의 SQL을 실행할 방법을 찾을 것이라고 가정해야 해요. 애플리케이션은 임의의 SQL 실행이 더 심각한 익스플로잇으로 확대되는 것을 방지하도록 설계되어야 해요.
- 몇몇 SQLite 확장은 포인터 전달의 이점을 얻어요:
- FTS3 MATCH 연산자는 snippet(), offsets(), matchinfo()에 포인터를 전달해요.
- carray 테이블-값 함수는 애플리케이션으로부터 C 언어 값 배열에 대한 포인터를 받아들여야 해요.
- remember() 확장은 전달하는 값을 기억할 C 언어 정수 변수에 대한 포인터가 필요해요.
- 애플리케이션은 커스텀 토크나이저 같은 확장을 FTS5에 추가하기 위해 "fts5_api" 객체에 대한 포인터를 받아야 해요.
- 포인터는 정수나 BLOB 같은 다른 SQL 데이터 타입으로 인코딩해 교환해서는 안 돼요. 대신 보안 포인터 전달을 촉진하도록 설계된 인터페이스를 사용해요: sqlite3_bind_pointer(), sqlite3_result_pointer(), sqlite3_value_pointer().
- 포인터 전달의 사용은 드물고 조심스럽게 사용해야 하는 고급 기법이에요. 포인터 전달은 아무렇게나 또는 부주의하게 사용해서는 안 돼요. 포인터 전달은 오용하면 깊은 흉터를 남길 수 있는 날카로운 도구예요.
- 각 포인터 전달 인터페이스의 마지막 매개변수인 "포인터 타입" 문자열은 API 호출에 직접 나타나는 뚜렷하고 애플리케이션 특정의 문자열 리터럴이어야 해요. 포인터 타입은 더 높은 수준의 함수에서 전달된 매개변수여서는 안 돼요.