TrustZone에서의 Memory Leak
루트 셸을 얻고 나면 기기의 비밀도 모두 볼 수 있을 거라고 생각하기 쉽다. 파일을 읽고, 프로세스를 살펴보고, 저장장치를 통째로 복사할 수 있으니까. 익명의 임베디드 기기를 분석하던 나도 처음에는 그 권한으로 암호화된 파티션의 구조를 빠르게 파악할 수 있을 거라고 기대했다.
그러나 내부 파티션의 구조는 암호화 되어 있었고, 디솔더링을 통해 분석을 하니 내가 필요한 파티션 정보는 Trustzone로 암호화 되어있었다. 이제는 결국 Trustzone 내부의 root key leak이 필요하게 된 것이다.
우리가 흔히 말하는 Android의 루트 권한은 uid=0이다. 운영체제 입장에서는 가장 강력한 사용자 권한이다. 그러나 ARM 기반 기기의 중요한 암호 작업은 운영체제와 분리된 Secure World에서 실행될 수 있다. 반면 Android, 앱, 시스템 서비스가 실행되는 곳은 Normal World다. ARM TrustZone은 이 둘을 구분하는 실행 환경이다.
Secure World에서 동작하는 앱을 TA(Trusted Application)라고 부른다. TA는 일반 앱처럼 Android의 프로세스 목록에 나타나지 않는다. Normal World에서 루트 셸을 얻었다고 TA 메모리에 디버거를 붙이거나 /proc에서 메모리 덤프를 읽을 수 있는 것도 아니다. 요청은 정해진 인터페이스를 따라 보안 영역으로 전달되고, TA가 돌려준 결과만 다시 일반 영역으로 온다.
Normal World Secure World
──────────────────── ────────────────────
Android 앱·시스템 서비스 TrustZone 런타임·TA
루트 셸(uid=0) 별도의 코드와 메모리
클라이언트 라이브러리 ── 보안 호출 ──→ 명령 처리
일반 영역의 응답 버퍼 ←── 결과 반환 ── TA가 허용한 출력
일반 운영체제의 서비스에서 명령 실행을 얻는 취약점과, 보안 앱이 자기 메모리를 밖으로 내보내는 취약점은 서로 다른 경계에서 일어난다. 전자는 TA를 조사할 발판을 주었다. 후자는 TA 코드 안에서 따로 찾아야 했다.
게다가 Secure World는 관찰하기도 까다롭다. 일반 앱처럼 중간에 멈춰서 레지스터와 스택을 읽기 어렵다. 잘못된 요청은 TA를 종료시키거나 기기를 재부팅시킬 수 있다. 커널 로그에 남은 오류가 TA 내부의 어느 명령에서 발생했는지 곧바로 알려 주지도 않는다. 그래서 이 분석에서는 펌웨어의 명령어, 호출 경로, 실기기의 실제 응답을 계속 서로 맞춰 봤다.
Normal World의 루트 셸
초기 분석의 출발점은 ADB와 관리용 Binder 서비스였다. 기기 안에는 로컬에서 동작하는 인증 없는 ADB 경로가 있었지만, PC에서 안정적인 루트 셸을 쓰기 위해서는 연결과 권한 문제를 더 풀어야 했다. 진짜 돌파구는 공장·관리 기능으로 보이는 서비스의 입력 처리에서 나왔다.
그 서비스의 한 메서드는 녹음에 사용할 파일명을 받아 tinycap 실행 문자열에 끼워 넣었다. 바이너리에서 복원한 명령의 형태는 다음과 같았다.
tinycap %s -D 0 -d 0 -i %d -c %d -r 48000 -b 16 -t %d
여기서 첫 번째 %s가 외부 Binder 요청에서 온 파일명이다. 코드는 이 값을 셸 명령에 안전한 하나의 파일 인자로 전달하지 않았다. 특별한 의미를 갖는 셸 문자가 들어와도 걸러내지 않은 채 명령 문자열에 결합했다. 파일명이어야 할 입력이 별도의 명령으로 해석될 수 있었던 이유다.
처음 얻은 것은 해당 서비스의 실행 문맥에서 명령을 수행하는 능력이었다. 초기 기록의 후속 과정에서는 system_server에 에이전트를 붙이고, 기존 ADB 데몬을 루트 권한으로 동작하는 데몬으로 교체하는 흐름을 사용했다. 이후 ADB 셸은 uid=0(root)를 반환했고, 새 데몬은 SELinux의 u:r:su:s0 문맥에서 실행됐다. 재부팅 후에도 접근을 유지하는 구성은 그 다음에 추가된 별도의 단계였다.
외부 입력이 셸 명령의 일부가 됨
→ 관리용 서비스 문맥에서 명령 실행
→ system_server를 거쳐 ADB 데몬 교체
→ Normal World의 루트 셸 확보
이 루트 셸 덕분에 기기의 파티션과 클라이언트 라이브러리를 조사할 수 있었다. 보안 앱을 호출하는 드라이버에 접근할 수 있었고, 일반 영역에서 만들어지는 요청도 볼 수 있었다. 그러나 루트 셸 자체가 TrustZone의 메모리를 읽어 준 것은 아니다. 그 다음 벽을 넘으려면 TA가 어떤 입력을 받고 어떤 출력을 내놓는지 알아야 했다.
암호화된 파티션이 가리킨 곳
저장장치를 조사하면서 사용자 데이터 파티션의 원시 덤프를 얻었다. 용량과 파티션 배치는 알 수 있었지만, 원시 이미지에서 파일시스템의 평문을 바로 읽을 수는 없었다. 부팅 로그와 키 자료를 따라가자 사용자 데이터의 키 해제 과정에 TEE의 Keymaster가 관여한다는 것을 확인했다. 덤프를 복사할 권한과 덤프를 복호할 키는 별개의 문제였다.
그 과정에서 다른 보안 앱도 눈에 들어왔다. 한 TA는 16바이트의 루트키(RK)를 다루고 있었다. 여기서 root는 앞서 얻은 운영체제 계정의 이름이 아니다. 여러 용도의 암호키를 파생하는 데 쓰이는 암호학적 비밀값을 가리킨다.
이 TA의 키 처리 경로에는 두 개의 낯선 이름이 등장한다. RPMB(Replay Protected Memory Block)는 저장장치의 보호된 영역으로, 기록의 무결성과 오래된 값의 재사용 방지를 고려한다. HUK(Hardware Unique Key)는 기기 하드웨어에 결속된 비밀값이다. TA는 RPMB의 보호된 키 자료를 읽고, HUK에 결속된 복호 경로를 거쳐 내부에서 루트키를 사용한다. 필요한 명령에는 그 원문을 그대로 내보내는 대신 용도별로 파생한 키를 사용한다.
RPMB의 보호된 자료
→ TA 안에서 HUK를 이용한 복호·검증
→ TA 내부의 16바이트 루트키
→ 용도별 키 파생 함수(KDF)
→ 각 기능에서 사용하는 키
여기서 사용자 데이터 암호화와 이 TA의 루트키를 같은 키로 단정하지는 않았다. 사용자 데이터 쪽에서 확인한 것은 Keymaster가 관여하는 키 해제 경로이고, 이 TA에서 확인한 것은 별도의 루트키 처리 경로다. 두 경로를 혼동하면 파티션 암호화의 구조도, 이번에 찾을 취약점의 영향도 잘못 설명하게 된다.
Chaining 과정
TA 바이너리를 손에 넣은 뒤에도 곧장 취약 함수를 찾을 수는 없었다. 일반 영역의 요청이 어느 코드를 거쳐 TA의 함수 인자로 들어가는지 먼저 파악해야 했다. 클라이언트 라이브러리는 보안 앱을 시작하고, 요청은 /dev/qseecom을 지나 커널 드라이버와 TrustZone 런타임에 전달된다. 그 뒤 TA의 진입점과 명령 디스패처에 도달한다.
일반 영역의 클라이언트
→ 클라이언트 라이브러리
→ /dev/qseecom과 커널 드라이버
→ TrustZone 런타임의 호출 래퍼
→ TA 진입점
→ 명령 디스패처
→ 실제 암호·저장 기능
보존된 호출 그래프에서는 libtee_util.so가 TA를 시작하고, 보안 영역의 CElfFile_invoke에서 등록된 핸들러를 거쳐 sub_11F64, 다시 명령 디스패처 sub_470으로 이어졌다. 우리가 관심을 가진 rpmb_stor_read_parameter는 그 디스패처에서 호출되는 파라미터 읽기 함수였다. 바이너리 안에 함수가 존재한다는 것과 외부 요청이 그 함수에 도달한다는 것을 이 연결로 구분할 수 있었다.
아키텍처도 한 번 더 함정이었다. 초기 기록의 일반 운영체제 사용자 공간은 32비트 실행 환경이었지만, 분석한 TA는 AArch64 코드였다. 양쪽의 포인터 크기와 호출 규약을 같은 것으로 취급할 수 없었다. 일반 영역에서 보낸 요청의 필드가 TA 안에서 어느 레지스터, 어느 버퍼가 되는지 각 경계를 따로 따라갔다.
분석과정
분석 도구도 역할을 나눴다. REA(Reverse Engineer Anything)로 현재 TA 추출물을 열어 ARM64 ELF라는 형식을 다시 확인했다. 이 해시는 우리가 분석하려는 바이너리가 이전의 다른 빌드가 아닌지 확인하는 기준이었다.
함수의 흐름을 읽는 데에는 보존된 IDA Professional 9.0·Hex-Rays 분석과 원본 바이트의 AArch64 objdump 결과를 사용했다. REA를 통해 함수 재디컴파일도 시도했지만 이 환경의 IDA 제공자 연결이 닫혀 결과를 받지 못했다. 따라서 아래에 실은 취약 코드의 근거는 REA가 새로 만든 의사코드가 아니라 원본 TA의 실제 명령어다. 도구가 보여 주는 보기 좋은 C 코드보다 주소 계산과 분기를 명령어에서 다시 확인한 이유도 여기에 있다.
파일마다 주소 기준이 다른 점도 직접 맞춰야 했다. 원시 코드 조각인 .b02에서는 파일 오프셋이 코드의 VA(가상 주소)와 같다. 전체 TA 이미지와 현재 ELF 추출물에서는 같은 코드가 파일의 0x3000 뒤에 있다. 예를 들어 코드 VA 0x8b98은 .b02의 오프셋 0x8b98, 전체 이미지의 오프셋 0xbb98에 해당한다. 이 차이를 놓치면 같은 주소를 보고도 엉뚱한 함수를 읽게 된다.
서명된 TA 이미지의 크기는 172,032바이트였다. 해시와 코드 바이트를 먼저 대조한 덕분에, 이전 버전의 함수 주소를 현재 실기기 코드에 잘못 옮기는 실수를 피할 수 있었다.
파라미터 ID 하나가 배열 밖으로 나갔다
TA에는 RPMB에 저장된 파라미터를 번호로 읽는 명령이 있었다. 파라미터 하나는 72바이트(0x48) 간격으로 배치된다. 자연스럽게 코드는 요청받은 ID에 72를 곱해 항목 위치를 구한다. 여기까지는 흔한 테이블 조회다.
문제는 그다음이었다. 함수는 계산한 항목에서 데이터 길이를 읽고, 그 길이만큼을 응답에 복사한다. 그런데 해당 ID가 실제 파라미터 영역 안에 있는지를 검사하는 상한 비교가 보이지 않았다. 유효한 항목 바깥의 메모리를 파라미터인 것처럼 해석하는 구조다. 이것이 RPMB 파라미터 인덱스 OOB(Out-of-Bounds) 읽기다.
아래는 원본 TA에서 뽑은 핵심 AArch64 명령어다. 왼쪽은 코드 VA이고, 주석은 분석을 위해 덧붙였다.
8b90 mov w9, #0x48 // 항목 하나의 크기: 72바이트
8b94 add x8, x8, #0x8 // 항목 테이블의 기준점
8b98 umaddl x9, w19, w9, x8 // 기준점 + (uint32)ID × 72
8b9c ldr w22, [x9, #4] // 계산된 위치에서 길이 읽기
8ba0 cbz w22, 0x8c04 // 0이면 복사하지 않음
...
8bac mov x0, x21 // 목적지: 응답 버퍼
8bb0 mov x2, x22 // 복사 길이
8bb4 madd x23, x9, x10, x8 // ID로 위치 다시 계산
8bb8 add x1, x23, #0x8 // 출발지: 항목 데이터 부분
8bbc bl 0x14318 // 복사 함수 호출
w19에는 외부에서 요청한 파라미터 ID가 들어 있다. umaddl은 32비트 ID를 부호 없는 값으로 확장해 곱하고 더한다. cbz는 읽어 온 길이가 0인지 확인할 뿐이다. 그 사이에는 ID가 유효한 항목 수보다 작은지 확인하는 비교가 없다.
uint8_t *item = table + (uint64_t)id * 0x48;
uint32_t length = *(uint32_t *)(item + 4);
if (length != 0) {
copy(response, item + 8, length);
}
이 코드를 처음 봤을 때 곧바로 “임의 주소를 읽을 수 있다”고 결론 내릴 수는 없었다. ID가 고르는 위치는 72바이트 간격이다. 복사 길이도 요청자가 원하는 숫자를 따로 넘기는 게 아니라 계산된 위치의 앞쪽 4바이트에서 읽힌다. 범위 밖에 도달하더라도 그곳이 접근 가능하고 길이로 해석할 값까지 있어야 실제 데이터가 나온다. 다음 작업은 그런 지점이 존재하는지 찾는 것이었다.
초기화와 매직 검사도 인덱스 검사를 대신하지 못했다
이 함수를 처음 훑으면 방어 코드가 전혀 없는 것처럼 보이지는 않는다. 진입 직후 큰 로컬 버퍼를 만들고 8192바이트를 0으로 초기화한다. RPMB에서 데이터를 읽은 뒤에는 저장 영역 앞부분의 매직 값도 비교한다. 저장된 자료가 예상한 형식인지 확인하려는 코드다.
8a94 sub sp, sp, #0x2000 // 8KB 로컬 공간 확보
8aa4 mov x0, sp
8aa8 mov w1, wzr // 0으로 채움
8aac mov w2, #0x2000 // 8192바이트
8ab0 bl 0x121dc // 초기화 함수 호출
...
8b84 cmp w8, w9 // 읽어 온 영역의 매직 비교
8b88 b.ne 0x8bf0 // 형식이 맞지 않으면 오류 경로
하지만 저장 영역이 정상 형식인지와 요청 ID가 그 영역 안에 있는지는 다른 검사다. 매직 값이 맞으면 코드는 곧바로 ID × 72 주소 계산으로 진행한다. 로컬 버퍼를 0으로 채웠다는 사실도, 큰 ID가 계산을 통해 그 버퍼의 끝을 넘어 호출자 스택을 가리키는 것을 막지 못한다. 보호 장치처럼 보였던 두 동작이 정작 필요한 인덱스 경계를 지켜 주지 않은 셈이다.
이 지점이 분석의 방향을 바꿨다. 처음에는 보호된 저장 자료를 파싱하는 로직을 보고 있었지만, 문제를 결정하는 값은 RPMB의 내용만이 아니었다. 함수가 호출될 때의 스택 배치가 길이와 복사 대상을 결정할 수 있었다. 그래서 RPMB 구조도와 함께 호출자의 스택 프레임을 계산하기 시작했다.
단서는 앞선 TA 명령의 스택 배치에 있었다. 그 명령이 사용한 값이 호출자 스택에 놓이고, 뒤따르는 파라미터 읽기 명령이 같은 스택의 더 낮은 위치에서 주소를 계산한다. ID 115를 대입하면 읽기 함수가 길이를 찾는 위치가 앞선 호출의 스택 필드와 겹쳤다.
앞선 명령의 처리 코드에서는 요청 필드를 조립한 값을 호출자 스택의 +0x20에 저장하는 명령이 있다. 뒤따르는 읽기 함수가 ID 115에서 길이로 해석하는 주소는 바로 그 저장 위치의 +4바이트 지점이다. 이것은 서로 다른 두 명령이 같은 입력 구조를 공유한다는 뜻이 아니다. 같은 실행 스택의 공간이 명령 사이에 재사용되면서, 한쪽의 임시 값이 다른 쪽의 범위 밖 파라미터 길이처럼 보이게 된 것이다.
830 str x8, [sp, #32] // 앞선 명령: 호출자 SP+0x20에 값 저장
...
8b9c ldr w22, [x9, #4] // 후속 읽기: ID 115에서 SP+0x24를 길이로 읽음
이런 시간차가 있는 스택 재사용은 코드를 한 함수씩 따로 보면 놓치기 쉽다. 앞선 명령의 반환값만 보면 실패처럼 보이고, 파라미터 읽기 함수를 따로 보면 RPMB 자료를 읽는 평범한 함수처럼 보인다. 두 명령의 스택 주소를 같은 기준으로 놓았을 때 비로소 연결이 드러났다.
계산을 직접 써 보자. 파라미터 읽기 함수는 진입할 때 64바이트를 저장하고, 추가로 0x2000바이트의 로컬 공간을 확보한다. 호출자의 스택 포인터를 P라고 하면 읽기 함수의 스택 포인터는 P−0x2040이다.
길이 필드 주소
= 읽기 함수 SP + 0x0c + ID × 0x48
= (P − 0x2040) + 0x0c + 115 × 0x48
= P + 0x24
복사할 데이터의 시작 = P + 0x28
정상 파라미터의 번호 범위를 벗어난 ID가 P+0x24를 길이, 바로 뒤의 P+0x28을 데이터 시작으로 보게 된 것이다. 디컴파일러가 임시 변수에 붙인 이름을 믿어서 얻은 결론이 아니다. 함수의 스택 할당 명령과 ID × 0x48 산술을 대입해 나온 위치다.
이런 계산이 TrustZone 분석에서 특히 중요하다. 실기기의 Secure World 스택을 일반 디버거로 펼쳐 놓고 확인할 수 없으므로, 함수 프롤로그에서 스택이 얼마나 내려갔는지, 저장한 레지스터가 어디에 놓였는지, 다음 호출이 같은 프레임을 어떻게 사용하는지를 바이너리에서 복원해야 한다. 주소 계산이 맞아도 그 위치의 내용까지 맞는지는 실제 응답으로 검증해야 했다.
응답에서 나온 384바이트
실기기에서는 앞선 명령에 mode=0x180을 사용한 뒤, 파라미터 ID 115를 읽었다. 앞선 명령 자체는 status=6을 반환했지만, 후속 읽기는 status=0으로 완료됐다.
start_app ret=0
plant cmd18 mode=0000000000000180 ret=0 status=6 rlen=256
leak cmd11 id=115 ret=0 status=0 rlen=384
핵심은 후속 읽기가 384바이트를 반환했다는 것이다. 원본 덤프의 SHA-256은 다음과 같다.
728e2d99f893149acde80eaa738aed9782ffb1a6f0b2c7ac505f17ba970ca7f3
길이만 맞았다고 스택 유출이라고 할 수는 없다. 반환된 바이트를 64비트 리틀엔디언 값으로 해석해 보니, 예상한 위치에 함수 프레임 포인터 X29와 복귀 주소 X30가 있었다. X29는 응답의 +0x18, X30은 +0x20에서 확인됐다. 앞서 계산한 데이터 시작 주소 P+0x28과 레지스터가 저장되는 위치가 일치했다.
여기서 앞선 명령의 status=6을 성공으로 둔갑시킬 필요도 없다. 그 명령이 반환한 상태값과, 그 뒤 스택에 남은 값이 다음 읽기의 길이로 해석됐다는 사실은 구분할 수 있다. 정적 코드에서 예측한 주소, 실기기 응답의 길이, 덤프 안의 레지스터 위치가 같은 그림을 가리켰다.
Secure World에서는 이 마지막 확인이 특히 값지다. 일반 앱이라면 디버거에서 메모리를 몇 번 더 읽어 볼 수 있지만, 여기서는 입력을 조금만 바꿔도 TA 종료나 기기 재부팅이 일어날 수 있다. 그래서 실측 덤프와 바이너리의 함수 프레임을 대조해 반환된 바이트가 무엇인지 확인했다.
복귀 주소가 알려 준 TA의 위치
덤프에 들어 있던 X30의 값은 0x73d8a1c4였다. ARM64에서 X30은 함수가 끝난 뒤 돌아갈 코드 주소를 담는 레지스터다. 이 주소가 현재 TA 바이너리의 어느 명령 다음을 가리키는지 알면, 실행 중인 TA가 메모리 어디에 놓였는지도 계산할 수 있다.
**ASLR(Address Space Layout Randomization)**은 실행 때마다 코드의 위치를 바꿔 주소 예측을 어렵게 만든다. 그러나 유출된 복귀 주소와 바이너리 내 상대 위치를 대조하면, 그 실행의 기준 주소가 드러난다.
실기기 덤프의 복귀 주소 0x73d8a1c4
바이너리 내 상대 위치 - 0x121c4
──────────────────────────────────
이 실행의 TA 기준 주소 0x73d78000
이 지점에서 처음의 질문으로 돌아온다. 루트 셸은 Secure World 메모리를 직접 읽지 못했다. 그런데 TA 자신의 파라미터 읽기 코드가 범위 밖 ID를 받아들이면서, TA 스택과 실행 주소의 일부를 응답으로 내보냈다. 경계가 무너진 위치는 Android의 루트 권한이 아니라 보안 앱의 입력 검증이었다.
마치며
이번 분석은 한 번에 보안 영역을 뒤집는 명령을 찾은 이야기가 아니다. Normal World의 명령 주입으로 루트 셸을 확보하고, 암호화된 파티션에서 TrustZone의 존재를 다시 확인하고, 클라이언트부터 TA까지 호출 경로를 따라갔다. REA로 분석 대상 ELF의 형식과 동일성을 확인했고, 저장된 IDA 분석과 원본 AArch64 명령어를 대조했다. 마지막으로 스택 산술이 가리킨 ID를 실기기 응답과 맞췄다.
그 결과 발견한 RPMB 파라미터 인덱스 OOB 읽기는 호출자 스택 384바이트를 반환했고, 그 안의 복귀 주소로 실행 중인 TA 기준 주소를 계산할 수 있었다. TrustZone이 일반 운영체제와 분리돼 있다는 사실은 여전히 중요하다.
또한 이 취약점은 아직 ASLR address 위치 정도만 bypass 가능하고, 이후 ROP Chaining 등 여러 취약점을 같이 트리거링 해야 루트키를 찾을 수 있다.