
Podczas analizy zdekompilowanego kodu różnych usług macOS natrafiłem na coś, co wyglądało jak podręcznikowa podatność typu format string w serwisie TCC (tccd). To właśnie ta usługa obsługuje wszystkie monity o pozwolenia prywatności, które widzisz. Na pierwszy rzut oka wywołanie asprintf bez przekazanych argumentów formatujących wygląda jak klasyczny błąd prowadzący do prostego exploita, prawda?
Jak się jednak okazało – nie do końca. Nie wszystkie format stringi są rzeczywistym problemem, jeśli są odpowiednio obsłużone na niższym poziomie. Ten artykuł dokumentuje taki przypadek na prawdziwym przykładzie. Miłej lektury!
Odkrycie
Podczas inżynierii wstecznej tccd znalazłem funkcję sub_10000189C, która zawierała problematyczną linię:

Każdy, kto zajmował się audytem bezpieczeństwa, rozpozna problem natychmiast – ten format string oczekuje dwóch argumentów %s, ale żaden nie jest przekazany. Poprawne użycie asprintf powinno wyglądać tak:
asprintf(&v2, "{Access:%s, reason:%s}", access_string, reason_string);Gdy asprintf spróbuje odczytać brakujące argumenty ze stosu, pobierze przypadkowe dane znajdujące się w tym miejscu, co potencjalnie może prowadzić do ujawnienia pamięci lub wykonania kodu. Przepisałem ten fragment kodu w formie proof-of-concept, aby zademonstrować podatność:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
char *vulnerable_function(uint64_t a1) {
char *v2 = NULL;
if ((a1 & 0x100000000000000ULL) != 0) {
if ((a1 & 0x200000000000000ULL) != 0) {
asprintf(&v2, "Auth:{Access:Unknown}");
} else {
asprintf(&v2, "{Access:%s, reason:%s}"); // Vulnerability here
}
} else {
asprintf(&v2, "Auth:{Invalid}");
}
return v2;
}
int main() {
uint64_t input = 0x100000000000001ULL;
char *result = vulnerable_function(input);
printf("Returned: %s\n", result);
free(result);
return 0;
}Kiedy uruchomimy to z AddressSanitizer, zobaczymy awarię związaną z nieprawidłowym odczytem pamięci, a nawet bez sanitizera kompilator i tak zgłosi ostrzeżenie:
❯ clang -fsanitize=address -o poc poc.c
poc.c:13:37: warning: more '%' conversions than data arguments [-Wformat-insufficient-args]
13 | asprintf(&v2, "{Access:%s, reason:%s}");
| ~^
1 warning generated.Przy uruchomieniu możemy zaobserwować błąd ASAN typu invalid read:
❯ ./poc
poc(15748,0x1f9fd5f00) malloc: nano zone abandoned due to inability to reserve vm space.
AddressSanitizer:DEADLYSIGNAL
=================================================================
==15748==ERROR: AddressSanitizer: SEGV on unknown address 0x000041b58ab3 (pc 0x0001052dc238 bp 0x00016b33e0e0 sp 0x00016b33d820 T0)
==15748==The signal is caused by a READ memory access.
#0 0x0001052dc238 in __sanitizer::internal_strlen(char const*)+0x4 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x54238)
#1 0x0001052a41a4 in vasprintf+0xa8 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x1c1a4)
#2 0x0001052a47b4 in asprintf+0x38 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x1c7b4)
#3 0x000104ac0958 in vulnerable_function poc.c:13
#4 0x000104ac0a3c in main poc.c:27
#5 0x00018bb26b94 in start+0x17b8 (dyld:arm64e+0xfffffffffff3ab94)
==15748==Register values:
x[0] = 0x0000000041b58ab3 x[1] = 0x0000000000000000 x[2] = 0x0000000000000000 x[3] = 0x000000016b33f2c8
x[4] = 0x0000000000000001 x[5] = 0x0000000000000020 x[6] = 0x0000000000000041 x[7] = 0x0000000000000000
x[8] = 0x0000000000000000 x[9] = 0x000000016b33e9a8 x[10] = 0x0000000000000073 x[11] = 0x0000007020978176
x[12] = 0x0000000000000002 x[13] = 0x0000000000000007 x[14] = 0xf9070000f9f9f9f9 x[15] = 0x0000000000000000
x[16] = 0x00000001052a477c x[17] = 0x00000001fb028228 x[18] = 0x0000000000000000 x[19] = 0x000000016b33e910
x[20] = 0x0000000104ac0baa x[21] = 0x0000000105319b7e x[22] = 0x0000000105319a73 x[23] = 0x0000000105319a92
x[24] = 0xaaaaaaaaaaaaaaaa x[25] = 0x000000016b33d880 x[26] = 0x0000000041b58ab3 x[27] = 0x00000000ffffffff
x[28] = 0x0000000000000068 fp = 0x000000016b33e0e0 lr = 0x00000001052a3768 sp = 0x000000016b33d820
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV poc.c:13 in vulnerable_function
==15748==ABORTING
[1] 15748 abort ./pocWyraźnie więc istnieje błąd – ale czy jest on wykorzystywalny?
Martwy kod
Kolejnym krokiem była próba ustalenia, jak wywołać to w faktycznym binarium tccd. IDA nie znalazła żadnych referencji do tej funkcji. Oznacza to, że nie wykryła żadnych wywołań, skoków ani odwołań do niej w swojej analizie.

To nie musi jednak oznaczać, że kod jest nieosiągalny. Czasami dekompilatory zawodzą, np. przy:
- wywołaniach pośrednich – funkcje przechowywane w tablicach skoków, tablicach wirtualnych albo wskaźnikach do funkcji,
- dynamicznym linkowaniu – jeśli binarium eksportuje funkcje, mogłyby być wywołane przez kod zewnętrzny
- rejestracji callbacków.
Gdyby to była biblioteka, funkcję można by znaleźć w tablicy eksportów.

Inne podejścia to:
- wyszukiwanie adresu funkcji (
10000189C) jako stałej w binarium, - analiza tablic wskaźników do funkcji w tym obszarze,
- sprawdzenie kodu inicjalizacyjnego, który mógłby rejestrować tę funkcję jako handler.
Jednak po analizie wyglądało na to, że funkcja faktycznie nigdy nie była wywoływana – to po prostu martwy kod pozostawiony w binarium.
Zabawa w Boga
Na tym etapie mogłem zamknąć sprawę. Martwy kod nie jest podatnością. Ale byłem ciekaw: co jeśli w przyszłych wersjach macOS ta funkcja jednak zostanie wywołana? Czy dałoby się ją wykorzystać? Aby to sprawdzić, postanowiłem „pobawić się w Boga” z debuggerem. Podpiąłem lldb do działającego procesu tccd i ręcznie przejąłem przepływ wykonania, zmuszając program do wejścia w sub_10000189C.
# Attach to the second tccd process (user space tccd)
lldb -p `pgrep tccd | awk 'NR==2'`
# Set a breakpoint on the vulnerable asprintf call (inside sub_10000189C)
br set -s tccd -n ___lldb_unnamed_symbol458 -R 112
# Manipulate the registers to force the execution path
register write x0 0x100000000000000
register write pc 0x10247189c # this is sub_10000189C
# Continue execution
cI tu pojawiła się niespodzianka. Nawet przy „podatnym” wywołaniu asprintf proces nie uległ awarii – działał poprawnie. Jak to możliwe?
Ratunek w asemblerze
Tuż przed wywołaniem asprintf była wywoływana inna funkcja: sub_100001770. Instrukcje asemblera wyglądały tak:
bl 0x102471770 ; wywołanie sub_100001770, zwraca reason string w x0
stp x19, x0, [sp] ; zapis access string (x19) i reason string (x0) na stos
adrp x1, 108
add x1, x1, #0x862 ; załadowanie format string "{Access:%s, reason:%s}" do x1
b 0x102471918 ; skok do wywołania asprintf
...
bl _asprintf ; wywołanie asprintfFunkcja sub_100001770 brała integera i zwracała odpowiadający mu ciąg tekstowy. Instrukcja stp x19, x0, [sp] umieszczała na stosie oba ciągi – access string i reason string – dokładnie tam, gdzie asprintf spodziewał się argumentów. Oto brakujące argumenty formatu:
0x16d98e190│+0000: 0x00000001024dd83e → ("Denied"?) ← $sp
0x16d98e198│+0008: 0x00000001024dd75c → ("None"?)W rzeczywistości były one dostarczane przez funkcję sub_100001770, która umieszczała prawidłowe wskaźniki do ciągów znaków na stosie dokładnie tam, gdzie asprintf spodziewał się je znaleźć. Zatem, chociaż kod jest technicznie niepoprawny na wysokim poziomie, ponieważ asprintf nie ma jawnych argumentów, działa na niskim poziomie, ze względu na konwencję wywołań i układ stosu. Ostatnim punktem awarii może być sub_100001770, ponieważ odpowiada on za to, co jest umieszczane na stosie. Oto pełny kod:
const char *__fastcall sub_100001770(__int64 a1)
{
if ( a1 > 5 )
{
if ( a1 <= 1001 )
{
switch ( a1 )
{
case 6LL:
return "Set";
case 1000LL:
return "Error";
case 1001LL:
return "Service Override";
}
}
else
{
if ( a1 <= 1003 )
{
if ( a1 == 1002 )
return "Missing Usage String";
else
return "Prompt Timeout";
}
if ( a1 == 1004 )
return "Preflight Unknown";
if ( a1 == 2000 )
return "Entitled";
}
return "<Unknown Reason>";
}
if ( a1 <= 2 )
{
switch ( a1 )
{
case 0LL:
return "None";
case 1LL:
return "Recorded";
case 2LL:
return "Service Default";
}
return "<Unknown Reason>";
}
if ( a1 == 3 )
return "Service Policy";
if ( a1 == 4 )
return "Compatibility Policy";
return "Override Policy";
}Jak widać, logika obsługuje wszystkie przypadki i zawsze zwraca poprawny string. Tym samym przekazuje go dalej na stos jako drugi argument.
Podsumowanie
Warto podkreślić, że kod źródłowy tccd jest zamknięty. Zdekompilowany kod nigdy nie jest idealnym odwzorowaniem oryginału. Optymalizacje kompilatora również mogły zmienić sposób działania. W oryginalnym kodzie asprintf mógł być wywoływany poprawnie z wszystkimi argumentami.
Analizowałem wersję tccd z macOS 15.5. W starszych wersjach kod mógł wyglądać inaczej lub być aktywnie wykorzystywany, co potencjalnie mogłoby uczynić format string realnym zagrożeniem.
Mam nadzieję, że ten wpis pokazuje, jak coś, co na pierwszy rzut oka wygląda jak oczywista podatność, wcale nie musi nią być.
Do następnego razu!





