Поиск уязвимых программ
Приемы, предложенные в разделе "Предотвращение ошибок переполнения", хорошо использовать при создании новых программ, а внедрять их в уже существующие и более или менее устойчиво работающие продукты – бессмысленно. Но ведь даже отлаженное и проверенное временем приложение не застраховано от наличия ошибок переполнения, которые годами могут спать, пока не будут кем-то обнаружены.
Самый простой и наиболее распространенный метод поиска уязвимостей заключается в методичном переборе всех возможных длин входных данных. Как правило, такая операция осуществляется не в ручную, а специальными автоматизированными средствами. Но таким способом обнаруживаются далеко не все ошибки переполнения! Наглядной демонстрацией этого утверждения служит следующая программа:
int file(char *buff)
{
char *p;
int a=0;
char proto[10];
p=strchr(&buff[0],':');
if (p)
{
for (;a!=(p-&buff[0]);a++) proto[a]=buff[a];
proto[a]=0;
if (strcmp(&proto[0],"file")) return 0;
else
WinExec(p+3,SW_SHOW);
}
else WinExec(&buff[0],SW_SHOW);
return 1;
}
main(int argc,char **argv)
{
if (argc>1) file(&argv[1][0]);
}
Листинг 1 Пример, демонстрирующий ошибку переполнения буферов
Она запускает файл, имя которого указано в командной строке. Попытка вызвать переполнение вводом строк различной длины, скорее всего, ни к чему не приведет. Но даже беглый анализ исходного кода позволит обнаружить ошибку, допущенную разработчиком.
Если в имени файла присутствует символ “:”, программа полагает, что имя записано в формате “протокол://путь к файлу/имя файла”, и пытается выяснить какой именно протокол был указан. При этом она копирует название протокола в буфер фиксированного размера, полагая, что при нормальном ходе вещей его хватит для вмещения имени любого протокола. Но если ввести строку наподобие “ZZZZZZZZZZZZZZZZZZZZZZ:”, произойдет переполнение буфера со всеми вытекающими отсюда последствиями.
Приведенный пример относится к одним из самых простых. На практике нередко встречаются и более коварные ошибки, проявляющиеся лишь при стечении множества маловероятных самих по себе обстоятельств. Обнаружить подобные уязвимости одним лишь перебором входных данных невозможно (тем не менее, даже такой поиск позволяет выявить огромное число ошибок в существующих приложениях).
Значительно лучший результат дает анализ исходных текстов программы. Чаще всего ошибки переполнения возникают вследствие путаницы между длинами и индексами массивов, выполнения операций сравнения до модификации переменной, небрежного обращения с условиями выхода из цикла, злоупотребления операторами "++" и "—", молчаливого ожидания символа завершения и т.д.
Например, конструкция “buff[strlen(str)-1]=0”, удаляющая символ возврата каретки, стоящий в конце строки, "спотыкаться" на строках нулевой длины, затирая при этом байт, предшествующий началу буфера.
Не менее опасна ошибка, допущенная в следующем фрагменте:
// …
fgets(&buff[0], MAX_STR_SIZE, stdin);
while(buff[p]!='\n') p++;
buff[p]=0;
// …
На первый взгляд все работает нормально, но если пользователь введет строку равную или превышающую MAX_STR_SIZE, функция fgets
автоматически отбросит ее хвост, вместе с символом возврата каретки. В результате этого цикл while выйдет за пределы сканируемого буфера и залезет в совсем не принадлежащую ему область памяти!
Так же часты ошибки, возникающие при преобразовании знаковых типов переменных в беззнаковые и наоборот. Классический пример такой ошибки – атака teardrop, возникающая при сборке TCP пакетов, один из которых находится целиков внутри другого. Отрицательное смещение конца второго пакета относительно конца первого, будучи преобразованным в беззнаковый тип, становится очень большим числом и выскакивает далеко за пределы отведенного ему буфера. Огромное число операционных систем, подверженных атаке teardrop наглядно демонстрирует каким осторожным следует быть при преобразовании типов переменных, и без особой необходимости такие преобразования и вовсе не следует проводить!
Вообще же, поиск ошибок – дело неблагодарное и чрезвычайно осложненное психологической инерцией мышления – программист подсознательно исключает из проверки те значения, которые противоречат "логике" и "здравому смыслу", но тем не менее могут встречаться на практике. Поэтому, легче решать эту задачу с обратного конца: сначала определить какие значения каждой переменной приводят к ненормальной работе кода (т.е. как бы смотреть на программу глазами взломщика), а уж потом выяснить выполняется ли проверка на такие значения или нет.
Особняком стоят проблемы многопоточных приложений и ошибки их синхронизации. Однопоточное приложение выгодно отличается воспроизводимостью аварийных ситуаций, - установив последователь операций, приводящих к проявлению ошибки, их можно повторить в любое время требуемое количество раз. Это значительно упрощает поиск и устранение источника их возникновения.
Напротив, неправильная синхронизация потоков (как и полное ее отсутствие), порождает трудноуловимые "плавающие" ошибки, проявляющиеся время от времени с некоторой (возможно пренебрежительно малой) вероятностью.
Рассмотрим простейший пример: пусть один поток модифицирует строку, и в тот момент, когда на место завершающего ее нуля помещен новый символ, а завершающий строку ноль еще не добавлен, второй поток пытается скопировать эту строку в свой буфер. Поскольку, завершающего нуля нет, происходит выход за границы массива со всеми вытекающими отсюда последствиями.
Поскольку, потоки в действительности выполняются не одновременно, а вызываются поочередно, получая в своей распоряжение некоторое (как правило, очень большое) количество "тиков" процессора, то вероятность прерывания потока в данном конкретном месте может быть очень мала и даже самое тщательное и широкомасштабное тестирование не всегда способно выловить такие ошибки.
Причем, вследствие трудностей воспроизведения аварийной ситуации, разработчики в подавляющем большинстве случаев не смогут быстро обнаружить и устранить допущенную ошибку, поэтому, пользователям придется довольно длительное время работать с уязвимым приложением, ничем не защищенным от атак злоумышленников.
Печально, что получив в свое распоряжение возможность делить процессы на потоки, многие программисты через чур злоупотребляют этим, применяя потоки даже там, где легко было бы обойтись и без них. Приняв во внимание сложность тестирования многопоточных приложений, стоит ли удивляется крайней нестабильности многих распространенных продуктов?
Не призывая разработчиков отказываться от потоков совсем, автор этой статьи хотел бы заметить, что гораздо лучше распараллеливать решение задач на уровне процессов. Достоинства такого подхода следующие: а) каждый процесс исполняется в собственном адресном пространстве и полностью изолирован от всех остальных; б) межпроцессорный обмен может быть построен по схеме, гарантирующей синхронность и когерентность данных; с) каждый процесс можно отлаживать независимо от остальных, рассматривая его как однопоточное приложения.
К сожалению, заменить потоки уже существующего приложения на процессы достаточно сложно и трудоемко. Но порой это все же гораздо проще, чем искать источник ошибок многопоточного приложения.