Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР #106, ОКТЯБРЬ 2007 г.

Трюки от Крыса

Крис Касперски

Хакер, номер #106, стр. 126

Сегодня мы поговорим о статических/динамических массивах нулевой длины. «А разве бывают такие?» — спросит недоверчивый читатель. Не только бывают, но и утверждены Стандартом, а также активно используются всеми, кто о них знает.

Трюк первый: статические массивы на стеке

Зачем может понадобиться создавать статический массив нулевой длины? Ведь выражение типа «char c[0]» не имеет смысла! Однако… в некоторых ситуациях оно бывает очень даже полезно. Допустим, мы имеем определение DATA_LEN с допустимыми значениями от 0 (no data) до… XXL. Тогда конструкция «char c[DATA_LEN]» при DATA_LEN == 0 приведет к ошибке компиляции, даже если мы не собираемся обращаться к массиву «c» по ходу исполнения программы. А усложнять алгоритм, добавляя лишние ветвления и загромождая листинг командами препроцессора, не хочется.

Вся хитрость в том, что если обернуть статический массив структурой, то компилятор проглотит ее, не задумываясь! Как раз то, что нам нужно! Рассмотрим следующий код:

Статический массив нулевой длины на стеке

#define DATA_LEN 0 // нет данных

struct ZERO // структура со статическим массивом нулевой длины

{

char c[DATA_LEN]; // массив нулевой длины

};

main()

{

// объявляем структуру с массивом нулевой длины

struct ZERO zero;

// печатаем размер структуры ZERO и ее экземпляра zero

printf("%x %x\n", sizeof(struct ZERO), sizeof(zero));

// присваиваем значение первой ячейке массива нулевой длины!!!

*zero.c = 0x69;

// выводим это значение на экран

printf("0%Xh\n", *zero.c);

}

Этот код компилируется всеми компиляторами без исключения, причем работает он правильно (хотя и не обязан этого делать). В частности, при компиляции Си-программы, Microsoft Visual C++ утверждает, что размер структуры ZERO равен 4 байтам, но если изменить расширение файла с «.c» на «.cpp», мы получим… 1 байт.

GCC во всех случаях дает нам 0 байт, что логично, но неправильно, поскольку все 32-битные компиляторы реально резервируют как минимум 4 байта под локальные переменные любых видов, поскольку это необходимо для выравнивания стека (и дизассемблерные листинги наглядно подтверждают это!).

Следовательно, мы можем не только создавать статические массивы нулевой длины, но еще и (пускай не без предосторожностей) использовать их. Например, в качестве вступительных тестов для новичков. Шутка! Но некоторая доля истины в ней есть. Обычно программисты, не желающие, чтобы их отстраняли от проекта, добавляют в исходный код немного «черной магии». Программа работает вопреки здравому смыслу и совершенно непостижимо для окружающих.

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

Трюк второй: динамические массивы на куче

Функции семейства malloc() обязаны корректно обрабатывать нулевой аргумент, возвращая валидный указатель на блок памяти нулевой длины. Вот что говорит MSDN по этому поводу: «If size is 0, malloc allocates a zero-length item in the heap and returns a valid pointer to that item» (Если размер [выделяемой памяти] равен нулю, функция malloc выделяет блок памяти нулевой длины в куче и возвращает указатель на него). То есть создавать массив нулевой длины на куче мы можем без всяких извращений со структурами. Вот только обращаться к созданному массиву (по стандарту) не можем никак. Стандарт допускает только проверку указателя на ноль, сравнение двух указателей, освобождение памяти, ну и, естественно, реаллокацию. Однако стандарт предполагает, а компилятор располагает.

Давай выясним: сколько же всего в действительности выделяется байт при создании массива нулевой длины?

Измерительный прибор, определяющий реальный размер массивов нулевой длины

#define DATA_LEN 0 // нет данных

main()

{

// создаем 3 массива нулевой длины

char *p1=malloc(DATA_LEN);

char *p2=malloc(DATA_LEN);

char *p3=malloc(DATA_LEN);

// создаем 3 массива длиной в 1 байт

char *p4=malloc(1);

char *p5=malloc(1);

char *p6=malloc(1);

// выводит указатель на созданные блоки на экран

printf("0%Xh\n0%Xh\n0%Xh\n\n0%Xh\n0%Xh\n0%Xh\n", p1, p2, p3, p4, p5, p6);

}

Откомпилировав программу с помощью Microsoft Visual C++ и запустив ее на выполнение, мы получим следующий результат:

Реально выделяемый размер динамических массивов

0300500h ; \

03004F0h ; +- указатели на блоки нулевой длины

03004E0h ; /

03004D0h ; \

03004C0h ; +- указатель на блоки длиной в 1 байт

03004B0h ; /

Как видно, адреса выделяемых блоков планомерно уменьшаются на 10h байт, следовательно, каждый блок (состоящий из массива и служебных данных) занимает намного больше, чем ничего. Более того, вызов malloc(0) эквивалентен malloc(1). Определить размер актуальных данных динамического массива несложно. Достаточно увеличивать аргумент, передаваемый malloc до тех пор, пока разница между соседними указателями скачкообразно не увеличится на некоторую величину.

Эксперимент показывает, что минимальный размер выделяемого блока для Microsoft Visual C++ и 32-битных версий GCC составляет 10h байт, то есть malloc(0) работает точно так же, как и malloc(0xF). Естественно, никаких гарантий, что остальные компиляторы поведут себя аналогичным образом, у нас нет и никогда не будет, поэтому вылезать за границы отведенного блока в любом случае не стоит.

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

Трюк третий: оператор new

Практически все известные мне компиляторы Си++ реализуют оператор new на основе malloc, поэтому все сказанное о malloc(0) справедливо и для new(0). Однако… кое-какие различия все-таки наблюдаются, и мне бы хотелось обратить на них внимание читателя.

Прежде всего откроем Стандарт (смотри «C++ Programming Language, Second Edition», секция 5.3.3), где Дохлый Страус прямо так и пишет: «This implies that an operator new() can be called with the argument zero. In this case, a pointer to an object is returned. Repeated such calls return pointers to distinct objects» («Отсюда следует, что оператор new() может вызываться с нулевым аргументом и возвращать валидный указатель на объект. Последовательный вызов new(0) возвращает указатели на различные объекты»).

Дальше по тексту объясняется, что мы можем получить указатель на нулевой объект, сравнивать его с любым другим указателем, но вот обращение к объекту нулевой длины Стандартом… ну не то чтобы запрещается, а отдается на откуп конкретным реализациям. Изучение исходных кодов RTL-библиотек различных компиляторов показывает, что new(0) в общем случае эквивалентно new(1) независимо от типа объекта.

Вот, например, фрагмент кода из GCC:

Фрагмент кода из компилятора GCC, реализующий оператор new

void* operator new(size_t size) // реализация оператора new

{

// если size равно нулю, принудительно устанавливаем размер в единицу

if( size == 0 ) size = 1;

…

// продолжение функции

…

}

Оператор new, в свою очередь, опирается на RTL-библиотеку, общую как для Си, так и для Си++, а потому оператор new(1) в большинстве случаев эквивалентен new(0xF), что наглядно подтверждает следующая программа:

Демонстрация создания объекта размером в 1 байт с помощью new char[0]

main()

{

// создаем символьный массив нулевой длины

// (Стандартом это допускается)

char *c = new char[0];

// получаем указатель на созданный объект нулевой длины

// (Стандартом это допускается)

char *p = &c[0];

// записываем в объект нулевой длины число 0x69

// (а вот этого Стандарт уже не допускает!!!)

*c=0x69;

// проверяем успешность записи числа, выводя его на экран

printf("0%Xh\n",*c);

}

Чтобы не быть голословным, мыщъх приводит дизассемблерный фрагмент вышеупомянутой программы, откомпилированной Microsoft Visual C++ (__heap_alloc – служебная функция, на которую опирается оператор new):

__heap_alloc proc near

arg_0 = dword ptr 8

push esi

mov esi, [esp+arg_0] ; размер выделяемой памяти

cmp esi, dword_406630 ; выделяем больше 1016 байт

ja short loc_401B87 ; если да, то прыжок

push esi ; обрабатываем ситуацию

call ___sbh_alloc_block ; с выделением более 1016 байт

test eax, eax ; памяти

pop ecx

jnz short loc_401BA3 ; прыжок, если памяти нет

loc_401B87:

test esi, esi ; выделяем ноль байт?!

jnz short loc_401B8E ; если не ноль - прыгаем

push 1 ; если ноль - увеличиваем

pop esi ; аргумент на единицу

loc_401B8E:

add esi, 0Fh ; округляем размер блока

and esi, 0FFFFFFF0h ; на 10h в большую сторону

push esi ; dwBytes

push 0 ; dwFlags

push hHeap ; hHeap

call ds:HeapAlloc ; выделяем блок памяти

loc_401BA3:

pop esi

retn

__heap_alloc endp

Содержание