Ссылка в никуда, или сломанный указатель
Язык программирования C/C++ и ему подобные можно по праву назвать «высокоуровневым ассемблером» благодаря их гибкости и свободе. Но у чрезмерной свободы существуют и свои недостатки, следует неустанно следить за тем, чтобы свобода одного не мешала свободе другого. Именно поэтому на программистов «свободных» (C/C++/Assembler) языков ложится все бремя ответственности за правильный ход выполнения программы (в других языках программирования за многим следит компилятор и не позволяет программисту допустить ту или иную ошибку). Сегодня мы разберем уязвимости, к которым может привести неправильное использование указателей и ссылок. Указатель представляет собой адрес определенной переменной в памяти, на которую он указывает. Такой подход во многом упрощает программирование, экономит такты процессора, позволяет более быстро обращаться к большим участкам памяти без их копирования. Приведем пример на языке C с использованием указателей и перейдем сразу к делу:
Листинг программы на C
#include <<>>
#include <<>>
int *p; //объявляем глобальный указатель типа integer
int test()
{
int test; //объявляем локальную переменную test
test=25; //присваиваем ей значение 25
p=&test; //присваиваем указателю p адрес переменной test
}
int test2()
{ int x=10; } //объявляем и присваиваем значение локальной переменной X=10.
int main()
{
test(); //вызываем функцию test
test2(); //вызываем функцию test2
printf("*P=%d",*p); //выводим на экран значение, которое находится по адресу в указателе p
return(0);
}
Итак, по коду несложно догадаться, что программа выведет в консоль число 25. Давайте скомпилируем данный пример, дабы убедиться в этом на практике. Представляю, как удивятся некоторые из вас, увидев, что p=10. Могу вас уверить, код выполнился, как ему и было положено, просто он содержит грубую ошибку, сейчас мы рассмотрим ее поподробнее.
Для этого нам нужно забраться в самое сердце программы, взглянуть на нее в дизассемблированном виде. Код, который будет приведен ниже, немного исправлен мной для лучшего понимания.
Листинг дизассемблированной программы
Функция test:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,4 ; Резервируем место для локальных переменных
MOV DWORD PTR SS:[EBP-4],19 ;копируем 19h=25d по адресу EBP-4 (test=25)
LEA EAX,DWORD PTR SS:[EBP-4] ;сохраняем в EAX адрес EBP-4(по сути, адрес пер. test)
MOV DWORD PTR DS:[443010],EAX ;сохраняем содержимое EAX в памяти(p=&test;)
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
Функция test2:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,4 ; Резервируем место для локальных переменных
MOV DWORD PTR SS:[EBP-4],0A ;копируем 0Ah=10d по адресу EBP-4 (x=10)
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
Функция main:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,18 ; Резервируем место для локальных переменных
CALL Project#.00401390 ; вызываем функцию test
CALL Project#.004013A8 ; вызываем функцию test2
MOV EAX,DWORD PTR DS:[443010] ; в EAX значение указателя p
MOV EAX,DWORD PTR DS:[EAX] ; в EAX содержимое, на адрес в p
MOV DWORD PTR SS:[ESP+4],EAX ; передаем printf значение, на которое указывает p
MOV DWORD PTR SS:[ESP], 00440000 ;передаем printf строку *P=%d
CALL PRINTF ;вызываем printf
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
В данном примере все кроется в глобальных и локальных переменных. Глобальные переменные — это такие переменные, которые «видны» всей программе сразу, к ним можно обратиться из любой функции или процедуры, они инициализируются при запуске программы. Локальные переменные видны только той процедуре или функции, в которой они объявлены. Локальные переменные инициализируются при запуске той или иной функции, место для них выделяется в стеке:
PUSH EBP
MOV EBP,ESP
SUB ESP,4
Выделяем 4 байта (одна переменная типа integer). После выполнения функции или процедуры кадр стека закрывается (leave):
MOV ESP,EBP
POP EBP
Но суть не в этом, а в том, что закрывая кадр стека, мы уничтожаем все переменные. То есть фактически, в памяти они остаются и не обнуляются, но обратиться к ним нельзя. Если раньше, до уничтожения, мы присвоили адрес переменной указателю (после уничтожения объекта он станет висячим указателем), то обратиться к ней можно. Но никто не гарантирует, что ее значение останется прежним, ведь программа уже не учитывает, что место занято, и может спокойно перезаписать значение на любое другое (как случайное, так и не очень). После выполнения функции test(), как раз это и произошло, P указывает на тот адрес, где раньше была переменная test. После, когда мы вызвали функцию test2(), открылся новый кадр стека, как раз в том же месте, где существовал кадр стека для функции test(), и мы присвоили X значение 10, а X находился по тому же адресу, по которому когда-то располагалась переменная test.
Таким образом, мы и получили в итоге 10. Такого рода ошибка называется «висячим указателем». Висячий указатель - указатель, ссылающийся на уже удаленный объект. Чем чревата подобная ошибка, спросите вы?
Первое, если бы я продолжал использовать (проводить арифметические и другие действия) над указателем на test, думая, что test у меня равна 25, то в итоге программа выдавала бы просто непредсказуемые результаты, вплоть до полного отказа ее работы.
Второе, при выполнении некоторых условий можно сознательно манипулировать значением переменной, а вдруг такая ошибка будет находиться в особо важном фрагменте кода, например в функции авторизации пользователя, а я смогу изменить значение ключевой переменной и авторизироваться без пароля! Замените функцию test2() на:
int test2()
{
int x;
scanf(“%d”,&x);
}
И убедитесь сами. Для того чтобы исправить ошибку, нужно объявить переменную test глобально или же использовать так называемые умные указатели.
Умный указатель — класс (обычно шаблонный), имитирующий интерфейс обычного указателя и добавляющий некую новую функциональность, например проверку границ при доступе или очистку памяти.
Говоря попроще, умный указатель уничтожает сам себя как только уничтожается объект, на который он указывает, что препятствует появлению висячих указателей. Помимо указателей, существует еще и понятие ссылки на объект. По своей сути она очень похожа на указатели; ссылка, можно сказать, второе имя переменной (псевдоним), по которому к ней можно обращаться, но она не хранит адреса в отличие от указателя и считается более безопасной, но это не спасает ее от существования такого понятия, как висячие ссылки. А не спасает вот почему. Ради чистого любопытства я решил проверить одну из своих догадок и написал два экземпляра кода, с использованием указателей и ссылок:
Листинг программы на C
Пример первый:
int main()
{
int a=7; //объявляем переменную типа integer, присваиваем ей значение 7
int* b=&a; //объявляем указатель, присваиваем ему адрес переменной a
}
Пример второй:
int main()
{
int a=7; //объявляем переменную типа integer, присваиваем ей значение 7
int& b=a ; //создаем псевдоним для переменной a
}
Скомпилировав первый и второй варианты, затем дизассемблировав их, посмотрел, какой код выходит в итоге:
Листинг дизассемблированной программы
MOV DWORD PTR SS:[EBP-4],7
LEA EAX,DWORD PTR SS:[EBP-4]
MOV DWORD PTR SS:[EBP-8],EAX
MOV EAX,0
LEAVE
RETN
Причем в первом и втором случае ассемблерный код оказался совершенно одинаковым! Это говорит о том, что на низком уровне ссылки и указатели — это одно и то же. Отличия можно наблюдать лишь на высоком уровне. Дело в том, что если вы попробуете использовать ссылку как обычный указатель, то компилятор просто откажется компилировать, хотя на уровне ассемблерных команд реализация ссылок и указателей одинакова.
Листинг неправильный код
int main()
{
int a=8;
int b=7;
int& c=b ;
*c=&a;
}
Я думаю, именно потому и существуют «висячие ссылки». Нужно быть предельно осторожным при проектировании своих программ и особое внимание уделять указателям и ссылкам, ведь падение или неправильное выполнение программы — еще не самое страшное, что может случиться, это еще одна, дополнительная лазейка для взломщика, которая может помочь ему в осуществлении коварных планов.
Kerny q@sa-sec.org
Листинг программы на C
#include <<
#include <<
int *p; //объявляем глобальный указатель типа integer
int test()
{
int test; //объявляем локальную переменную test
test=25; //присваиваем ей значение 25
p=&test; //присваиваем указателю p адрес переменной test
}
int test2()
{ int x=10; } //объявляем и присваиваем значение локальной переменной X=10.
int main()
{
test(); //вызываем функцию test
test2(); //вызываем функцию test2
printf("*P=%d",*p); //выводим на экран значение, которое находится по адресу в указателе p
return(0);
}
Итак, по коду несложно догадаться, что программа выведет в консоль число 25. Давайте скомпилируем данный пример, дабы убедиться в этом на практике. Представляю, как удивятся некоторые из вас, увидев, что p=10. Могу вас уверить, код выполнился, как ему и было положено, просто он содержит грубую ошибку, сейчас мы рассмотрим ее поподробнее.
Для этого нам нужно забраться в самое сердце программы, взглянуть на нее в дизассемблированном виде. Код, который будет приведен ниже, немного исправлен мной для лучшего понимания.
Листинг дизассемблированной программы
Функция test:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,4 ; Резервируем место для локальных переменных
MOV DWORD PTR SS:[EBP-4],19 ;копируем 19h=25d по адресу EBP-4 (test=25)
LEA EAX,DWORD PTR SS:[EBP-4] ;сохраняем в EAX адрес EBP-4(по сути, адрес пер. test)
MOV DWORD PTR DS:[443010],EAX ;сохраняем содержимое EAX в памяти(p=&test;)
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
Функция test2:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,4 ; Резервируем место для локальных переменных
MOV DWORD PTR SS:[EBP-4],0A ;копируем 0Ah=10d по адресу EBP-4 (x=10)
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
Функция main:
PUSH EBP ; открываем кадр стека
MOV EBP,ESP
SUB ESP,18 ; Резервируем место для локальных переменных
CALL Project#.00401390 ; вызываем функцию test
CALL Project#.004013A8 ; вызываем функцию test2
MOV EAX,DWORD PTR DS:[443010] ; в EAX значение указателя p
MOV EAX,DWORD PTR DS:[EAX] ; в EAX содержимое, на адрес в p
MOV DWORD PTR SS:[ESP+4],EAX ; передаем printf значение, на которое указывает p
MOV DWORD PTR SS:[ESP], 00440000 ;передаем printf строку *P=%d
CALL PRINTF ;вызываем printf
LEAVE ; закрываем кадр стека
RETN ; выходим из функции
В данном примере все кроется в глобальных и локальных переменных. Глобальные переменные — это такие переменные, которые «видны» всей программе сразу, к ним можно обратиться из любой функции или процедуры, они инициализируются при запуске программы. Локальные переменные видны только той процедуре или функции, в которой они объявлены. Локальные переменные инициализируются при запуске той или иной функции, место для них выделяется в стеке:
PUSH EBP
MOV EBP,ESP
SUB ESP,4
Выделяем 4 байта (одна переменная типа integer). После выполнения функции или процедуры кадр стека закрывается (leave):
MOV ESP,EBP
POP EBP
Но суть не в этом, а в том, что закрывая кадр стека, мы уничтожаем все переменные. То есть фактически, в памяти они остаются и не обнуляются, но обратиться к ним нельзя. Если раньше, до уничтожения, мы присвоили адрес переменной указателю (после уничтожения объекта он станет висячим указателем), то обратиться к ней можно. Но никто не гарантирует, что ее значение останется прежним, ведь программа уже не учитывает, что место занято, и может спокойно перезаписать значение на любое другое (как случайное, так и не очень). После выполнения функции test(), как раз это и произошло, P указывает на тот адрес, где раньше была переменная test. После, когда мы вызвали функцию test2(), открылся новый кадр стека, как раз в том же месте, где существовал кадр стека для функции test(), и мы присвоили X значение 10, а X находился по тому же адресу, по которому когда-то располагалась переменная test.
Таким образом, мы и получили в итоге 10. Такого рода ошибка называется «висячим указателем». Висячий указатель - указатель, ссылающийся на уже удаленный объект. Чем чревата подобная ошибка, спросите вы?
Первое, если бы я продолжал использовать (проводить арифметические и другие действия) над указателем на test, думая, что test у меня равна 25, то в итоге программа выдавала бы просто непредсказуемые результаты, вплоть до полного отказа ее работы.
Второе, при выполнении некоторых условий можно сознательно манипулировать значением переменной, а вдруг такая ошибка будет находиться в особо важном фрагменте кода, например в функции авторизации пользователя, а я смогу изменить значение ключевой переменной и авторизироваться без пароля! Замените функцию test2() на:
int test2()
{
int x;
scanf(“%d”,&x);
}
И убедитесь сами. Для того чтобы исправить ошибку, нужно объявить переменную test глобально или же использовать так называемые умные указатели.
Умный указатель — класс (обычно шаблонный), имитирующий интерфейс обычного указателя и добавляющий некую новую функциональность, например проверку границ при доступе или очистку памяти.
Говоря попроще, умный указатель уничтожает сам себя как только уничтожается объект, на который он указывает, что препятствует появлению висячих указателей. Помимо указателей, существует еще и понятие ссылки на объект. По своей сути она очень похожа на указатели; ссылка, можно сказать, второе имя переменной (псевдоним), по которому к ней можно обращаться, но она не хранит адреса в отличие от указателя и считается более безопасной, но это не спасает ее от существования такого понятия, как висячие ссылки. А не спасает вот почему. Ради чистого любопытства я решил проверить одну из своих догадок и написал два экземпляра кода, с использованием указателей и ссылок:
Листинг программы на C
Пример первый:
int main()
{
int a=7; //объявляем переменную типа integer, присваиваем ей значение 7
int* b=&a; //объявляем указатель, присваиваем ему адрес переменной a
}
Пример второй:
int main()
{
int a=7; //объявляем переменную типа integer, присваиваем ей значение 7
int& b=a ; //создаем псевдоним для переменной a
}
Скомпилировав первый и второй варианты, затем дизассемблировав их, посмотрел, какой код выходит в итоге:
Листинг дизассемблированной программы
MOV DWORD PTR SS:[EBP-4],7
LEA EAX,DWORD PTR SS:[EBP-4]
MOV DWORD PTR SS:[EBP-8],EAX
MOV EAX,0
LEAVE
RETN
Причем в первом и втором случае ассемблерный код оказался совершенно одинаковым! Это говорит о том, что на низком уровне ссылки и указатели — это одно и то же. Отличия можно наблюдать лишь на высоком уровне. Дело в том, что если вы попробуете использовать ссылку как обычный указатель, то компилятор просто откажется компилировать, хотя на уровне ассемблерных команд реализация ссылок и указателей одинакова.
Листинг неправильный код
int main()
{
int a=8;
int b=7;
int& c=b ;
*c=&a;
}
Я думаю, именно потому и существуют «висячие ссылки». Нужно быть предельно осторожным при проектировании своих программ и особое внимание уделять указателям и ссылкам, ведь падение или неправильное выполнение программы — еще не самое страшное, что может случиться, это еще одна, дополнительная лазейка для взломщика, которая может помочь ему в осуществлении коварных планов.
Kerny q@sa-sec.org
Компьютерная газета. Статья была опубликована в номере 45 за 2009 год в рубрике программирование