Страница:
Целое со знаком в более широкое целое со знаком. Меньшее значение расширяется со знаком, например приведение (char)0x7f к int дает 0x0000007f, но (char)0x80 становится равно 0xffffff80.
Целое со знаком в целое без знака того же размера. Комбинация битов сохраняется, значение может измениться или остаться неизменным. Так, (char)0xff (-1) после приведения к типу unsigned char становится равно 0xff, но ясно, что–1 и 255 – это не одно и то же.
Целое со знаком в более широкое целое без знака. Здесь сочетаются два предыдущих правила. Сначала производится расширение со знаком до знакового типа нужного размера, а затем приведение с сохранением комбинации битов. Это означает, что положительные числа ведут себя ожидаемым образом, а отрицательные могут дать неожиданный результат. Например, (char) -1 (0xff) после приведения к типу unsigned long становится равно 4 294 967 295 (0xffffffff).
Целое без знака в более широкое целое без знака. Это простейший случай: новое число дополняется нулями, чего вы обычно и ожидаете. Следовательно, (unsigned char)0xff после приведения к типу unsigned long становится равно
0x000000ff.
Целое без знака в целое со знаком того же размера. Так же как при приведении целого со знаком к целому без знака, комбинация битов сохраняется, а значение может измениться в зависимости от того, был ли старший (знаковый) бит равен 1 или 0.
Целое без знака в более широкое целое со знаком. Так же как при приведении целого без знака к более широкому целому без знака, значение сначала дополняется нулями до нужного беззнакового типа, а затем приводится к знаковому типу. Значение не изменяется, так что никаких сюрпризов в этом случае не бывает.
Понижающее приведение. Если в исходном числе хотя бы один из старших битов был отличен от нуля, то мы имеем усечение, что вполне может привести к печальным последствиям. Возможно, что число без знака станет отрицательным или произойдет потеря информации. Если речь не идет о битовых масках, всегда проверяйте, не было ли усечения.
Преобразования при вызове операторов
Арифметические операции
Операции сравнения
Поразрядные операции
Греховность С#
Ключевые слова checked и unchecked
Греховность Visual Basic и Visual Basic .NET
Греховность Java
Греховность Perl
Где искать ошибку
Выявление ошибки на этапе анализа кода
C/C++
С#
Целое со знаком в целое без знака того же размера. Комбинация битов сохраняется, значение может измениться или остаться неизменным. Так, (char)0xff (-1) после приведения к типу unsigned char становится равно 0xff, но ясно, что–1 и 255 – это не одно и то же.
Целое со знаком в более широкое целое без знака. Здесь сочетаются два предыдущих правила. Сначала производится расширение со знаком до знакового типа нужного размера, а затем приведение с сохранением комбинации битов. Это означает, что положительные числа ведут себя ожидаемым образом, а отрицательные могут дать неожиданный результат. Например, (char) -1 (0xff) после приведения к типу unsigned long становится равно 4 294 967 295 (0xffffffff).
Целое без знака в более широкое целое без знака. Это простейший случай: новое число дополняется нулями, чего вы обычно и ожидаете. Следовательно, (unsigned char)0xff после приведения к типу unsigned long становится равно
0x000000ff.
Целое без знака в целое со знаком того же размера. Так же как при приведении целого со знаком к целому без знака, комбинация битов сохраняется, а значение может измениться в зависимости от того, был ли старший (знаковый) бит равен 1 или 0.
Целое без знака в более широкое целое со знаком. Так же как при приведении целого без знака к более широкому целому без знака, значение сначала дополняется нулями до нужного беззнакового типа, а затем приводится к знаковому типу. Значение не изменяется, так что никаких сюрпризов в этом случае не бывает.
Понижающее приведение. Если в исходном числе хотя бы один из старших битов был отличен от нуля, то мы имеем усечение, что вполне может привести к печальным последствиям. Возможно, что число без знака станет отрицательным или произойдет потеря информации. Если речь не идет о битовых масках, всегда проверяйте, не было ли усечения.
Преобразования при вызове операторов
Большинство программистов не подозревают, что одного лишь обращения к оператору достаточно для изменения типа результата. Обычно ничего страшного не происходит, но граничные случаи могут вас неприятно удивить. Вот код на С++, иллюстрирующий проблему:
□ если хотя бы один операнд имеет тип unsigned long, то оба операнда приводятся к типу unsigned long. Строго говоря, long и int – это два разных типа, но на современных машинах тот и другой имеют длину 32 бита, поэтому компилятор считает их эквивалентными;
□ во всех остальных случаях, когда длина операнда составляет 32 бита или меньше, операнды расширяются до типа int, и результатом является значение типа int.
Как правило, ничего неожиданного при этом не происходит, и неявное приведение в результате применения операторов может даже помочь избежать некоторых переполнений. Но бывают и сюрпризы. Во–первых, в системах, где имеется тип 64–разрядного целого, было бы логично ожидать, что коль скоро unsigned short и signed short приводятся к int, а операторное приведение не нарушает корректность результата (по крайней мере, если вы потом не выполняете понижающего приведения до 16 битов), то unsigned int и signed int будут приводиться к 64–разрядному типу (_int64). Если вы думаете, что все так и работает, то вынуждены вас разочаровать – по крайней мере, до той поры, когда стандарт C/C++ не станет трактовать 64–разрядные целые так же, как остальные.
Вторая неожиданность заключается в том, что поведение изменяется еще и в зависимости от оператора. Все арифметические операторы (+, – ,*,/,%) подчиняются приведенным выше правилам. Но им же подчиняются и поразрядные бинарные операторы (&,|,А); поэтому (unsigned short) | (unsigned short) дает int! Те же правила в языке С распространяются на булевские операторы (&&,|| и !), тогда как в С++ возвращается значение встроенного типа bool. Дополнительную путаницу вносит тот факт, что одни унарные операторы модифицируют тип, а другие–нет. Оператор дополнения до единицы (~) изменяет тип результата, поэтому -((unsigned short)0) дает int, тогда как операторы префиксного и постфиксного инкремента и декремента (++, – ) типа не меняют.
Один программист с многолетним стажем работы предложил следующий код для проверки того, возникнет ли переполнение при сложении двух 16–разрядных целых без знака:
Вспомним из предыдущего обсуждения, какой тип имеет результат операции unsigned short + unsigned short. Это int. Каковы бы ни были значения целых без знака, результат никогда не может переполнить тип int, поэтому сложение всегда выполняется корректно. Далее int сравнивается с unsigned short. Значение х приводится к типу int и, стало быть, никогда не будет больше х + у. Чтобы исправить код, нужно лишь привести результат обратно к unsigned short:
template <typename T>Для простоты оставим в стороне случай смешанных операций над целыми и числами с плавающей точкой. Правила формулируются так:
void WhatIsIt(T value)
{
if((T)-1 < 0)
printf("Со знаком");
else
printf("Без знака");
printf(" – %d бит\n", sizeof(T)*8);
}
□ если хотя бы один операнд имеет тип unsigned long, то оба операнда приводятся к типу unsigned long. Строго говоря, long и int – это два разных типа, но на современных машинах тот и другой имеют длину 32 бита, поэтому компилятор считает их эквивалентными;
□ во всех остальных случаях, когда длина операнда составляет 32 бита или меньше, операнды расширяются до типа int, и результатом является значение типа int.
Как правило, ничего неожиданного при этом не происходит, и неявное приведение в результате применения операторов может даже помочь избежать некоторых переполнений. Но бывают и сюрпризы. Во–первых, в системах, где имеется тип 64–разрядного целого, было бы логично ожидать, что коль скоро unsigned short и signed short приводятся к int, а операторное приведение не нарушает корректность результата (по крайней мере, если вы потом не выполняете понижающего приведения до 16 битов), то unsigned int и signed int будут приводиться к 64–разрядному типу (_int64). Если вы думаете, что все так и работает, то вынуждены вас разочаровать – по крайней мере, до той поры, когда стандарт C/C++ не станет трактовать 64–разрядные целые так же, как остальные.
Вторая неожиданность заключается в том, что поведение изменяется еще и в зависимости от оператора. Все арифметические операторы (+, – ,*,/,%) подчиняются приведенным выше правилам. Но им же подчиняются и поразрядные бинарные операторы (&,|,А); поэтому (unsigned short) | (unsigned short) дает int! Те же правила в языке С распространяются на булевские операторы (&&,|| и !), тогда как в С++ возвращается значение встроенного типа bool. Дополнительную путаницу вносит тот факт, что одни унарные операторы модифицируют тип, а другие–нет. Оператор дополнения до единицы (~) изменяет тип результата, поэтому -((unsigned short)0) дает int, тогда как операторы префиксного и постфиксного инкремента и декремента (++, – ) типа не меняют.
Один программист с многолетним стажем работы предложил следующий код для проверки того, возникнет ли переполнение при сложении двух 16–разрядных целых без знака:
bool IsValidAddition(unsigned short x, unsigned short y)Вроде бы должно работать. Если результат сложения двух положительных чисел оказывается меньше какого–то слагаемого, очевидно, что–то не в порядке. Точно такой же код должен работать и для чисел типа unsigned long. Увы, программист не учел, что компилятор оптимизирует всю функцию так, что она будет возвращать true.
{
if(x + y < x)
return false;
return true;
}
Вспомним из предыдущего обсуждения, какой тип имеет результат операции unsigned short + unsigned short. Это int. Каковы бы ни были значения целых без знака, результат никогда не может переполнить тип int, поэтому сложение всегда выполняется корректно. Далее int сравнивается с unsigned short. Значение х приводится к типу int и, стало быть, никогда не будет больше х + у. Чтобы исправить код, нужно лишь привести результат обратно к unsigned short:
if((unsigned short)(x + y) < x)Этот код был показан хакеру, специализирующемуся на поиске ошибок, связанных с переполнением целых, и он тоже не заметил ошибки, так что наш опытный программист не одинок!
Арифметические операции
Не упускайте из виду последствия приведений типов и применения операторов, размышляя над корректностью той или иной строки кода, – в результате неявных приведений может возникнуть переполнение. Вообще говоря, нужно рассмотреть четыре основных случая: операции только над знаковыми типами, только над беззнаковыми типами и смешанные операции. Проще всего операции над беззнаковыми типами одного размера, затем идут операции над знаковыми типами, а когда встречаются смешанные операции, нужно принять во внимание правила приведения. В следующих разделах мы обсудим возможные ошибки и способы их исправления для каждого случая.
Сложение и вычитание. Очевидная проблема при выполнении этих операций – возможность перехода через верхнюю и нижнюю границы объявленного типа. Например, если речь идет о 8–разрядных числах без знака, то 255 + 1 = 0. Или: 2 – 3 = 255. В случае 8–разрядных чисел со знаком 127 + 1 = -128. Менее очевидная ошибка возникает, когда числа со знаком используются для представления размеров. Если кто–то подсунет вам число–20, вы прибавите его к 50, получите 30, выделите буфер длиной 30 байтов, а затем попытаетесь скопировать в него 50 байтов. Все, вы стали жертвой хакера. Помните, особенно при программировании на языке, где переполнить целое трудно или невозможно, – что вычитание из положительного числа, в результате которого получается число, меньшее исходного, – это допустимая операция, и никакого исключения вследствие переполнения не будет, но поток исполнения программы может отличаться от ожидаемого. Если вы предварительно не проверили, что входные данные попадают в положенный диапазон, и не уверены на сто процентов, что переполнение невозможно, контролируйте каждую операцию.
Умножение, деление и вычисление остатка. Умножение чисел без знака не вызывает трудностей: любая операция, где а * b > MAX_INT, дает некорректный результат. Правильный, но не очень эффективный способ контроля заключается в том, чтобы проверить, что b > MAX_INT/a. Эффективнее сохранить результат в следующем по ширине целочисленном типе (если такой существует) и посмотреть, не возникло ли переполнение. Для небольших целых чисел это сделает за вас компилятор. Напомним, что short * short дает int. При умножении чисел со знаком нужно еще проверить, не оказался ли результат отрицательным вследствие переполнения.
Ну а может ли вызвать проблемы операция деления, помимо, конечно, деления на нуль? Рассмотрим 8–разрядное целое со знаком: MIN_INT = -128. Разделим его на–1. Это то же самое, что написать -(-128). Операцию дополнения можно записать в виде ~х+1. Дополнение–128 (0x80) до единицы равно 127 или 0x7f. Прибавим 1 и получим 0x80! Итак, минус–128 снова равно–128! То же верно для деления на–1 минимального целого любого знакового типа. Если вы еще не уверены, что контролировать операции над числами без знака проще, надеемся, что этот пример вас убедил.
Оператор деления по модулю возвращает остаток от деления одного числа на другое, поэтому мы никогда не получим результат, который по абсолютной величине больше числителя. Ну и как тут может возникнуть переполнение? Переполнения как такового и не возникает, но результат может оказаться неожиданным из–за правил приведения. Рассмотрим 32–разрядное целое без знака, равное MAX_INT, то есть 0xffffffff, и 8–разрядное целое со знаком, равное–1. Остаток от деления–1 на 4 294 967 295 равен 1, не так ли? Не торопитесь. Компилятор желает работать с похожими числами, поэтому приведет–1 к типу unsigned int. Напомним, как это происходит. Сначала число расширяется со знаком до 32 битов, поэтому из 0xff получится 0xffffffff. Затем (int)(0xffffffff) преобразуется в (unsigned int)(0xffffffff). Как видите, остаток от деления–1 на 4 млрд равен нулю, по крайней мере, на нашем компьютере! Аналогичная проблема возникает при смешанной операции над любыми 32–или 64–разрядными целыми без знака и отрицательными целыми со знаком, причем это относится также и к делению, так что–1/4 294 967 295 равно 1, что весьма странно, ведь вы ожидали получить 0.
Сложение и вычитание. Очевидная проблема при выполнении этих операций – возможность перехода через верхнюю и нижнюю границы объявленного типа. Например, если речь идет о 8–разрядных числах без знака, то 255 + 1 = 0. Или: 2 – 3 = 255. В случае 8–разрядных чисел со знаком 127 + 1 = -128. Менее очевидная ошибка возникает, когда числа со знаком используются для представления размеров. Если кто–то подсунет вам число–20, вы прибавите его к 50, получите 30, выделите буфер длиной 30 байтов, а затем попытаетесь скопировать в него 50 байтов. Все, вы стали жертвой хакера. Помните, особенно при программировании на языке, где переполнить целое трудно или невозможно, – что вычитание из положительного числа, в результате которого получается число, меньшее исходного, – это допустимая операция, и никакого исключения вследствие переполнения не будет, но поток исполнения программы может отличаться от ожидаемого. Если вы предварительно не проверили, что входные данные попадают в положенный диапазон, и не уверены на сто процентов, что переполнение невозможно, контролируйте каждую операцию.
Умножение, деление и вычисление остатка. Умножение чисел без знака не вызывает трудностей: любая операция, где а * b > MAX_INT, дает некорректный результат. Правильный, но не очень эффективный способ контроля заключается в том, чтобы проверить, что b > MAX_INT/a. Эффективнее сохранить результат в следующем по ширине целочисленном типе (если такой существует) и посмотреть, не возникло ли переполнение. Для небольших целых чисел это сделает за вас компилятор. Напомним, что short * short дает int. При умножении чисел со знаком нужно еще проверить, не оказался ли результат отрицательным вследствие переполнения.
Ну а может ли вызвать проблемы операция деления, помимо, конечно, деления на нуль? Рассмотрим 8–разрядное целое со знаком: MIN_INT = -128. Разделим его на–1. Это то же самое, что написать -(-128). Операцию дополнения можно записать в виде ~х+1. Дополнение–128 (0x80) до единицы равно 127 или 0x7f. Прибавим 1 и получим 0x80! Итак, минус–128 снова равно–128! То же верно для деления на–1 минимального целого любого знакового типа. Если вы еще не уверены, что контролировать операции над числами без знака проще, надеемся, что этот пример вас убедил.
Оператор деления по модулю возвращает остаток от деления одного числа на другое, поэтому мы никогда не получим результат, который по абсолютной величине больше числителя. Ну и как тут может возникнуть переполнение? Переполнения как такового и не возникает, но результат может оказаться неожиданным из–за правил приведения. Рассмотрим 32–разрядное целое без знака, равное MAX_INT, то есть 0xffffffff, и 8–разрядное целое со знаком, равное–1. Остаток от деления–1 на 4 294 967 295 равен 1, не так ли? Не торопитесь. Компилятор желает работать с похожими числами, поэтому приведет–1 к типу unsigned int. Напомним, как это происходит. Сначала число расширяется со знаком до 32 битов, поэтому из 0xff получится 0xffffffff. Затем (int)(0xffffffff) преобразуется в (unsigned int)(0xffffffff). Как видите, остаток от деления–1 на 4 млрд равен нулю, по крайней мере, на нашем компьютере! Аналогичная проблема возникает при смешанной операции над любыми 32–или 64–разрядными целыми без знака и отрицательными целыми со знаком, причем это относится также и к делению, так что–1/4 294 967 295 равно 1, что весьма странно, ведь вы ожидали получить 0.
Операции сравнения
Ну уж сравнение на равенство–то должно работать, правда? Увы, если вы имеете дело с комбинацией целых со знаком и без знака, то таких гарантий никто не дает, по крайней мере в случае, когда знаковый тип шире беззнакового. Та же проблема, с которой мы столкнулись при рассмотрении деления и вычисления остатка, возникает и здесь и приводит к тем же последствиям.
Операции сравнения могут преподнести и другую неожиданность – когда максимальный размер сравнивается с числом со знаком. Противник может найти способ сделать это число отрицательным, а тогда оно заведомо будет меньше верхнего предела. Либо пользуйтесь числами без знака (это рекомендуемый способ), либо делайте две проверки: сначала проверяйте, что число больше или равно нулю, а потом – что оно меньше верхнего предела.
Операции сравнения могут преподнести и другую неожиданность – когда максимальный размер сравнивается с числом со знаком. Противник может найти способ сделать это число отрицательным, а тогда оно заведомо будет меньше верхнего предела. Либо пользуйтесь числами без знака (это рекомендуемый способ), либо делайте две проверки: сначала проверяйте, что число больше или равно нулю, а потом – что оно меньше верхнего предела.
Поразрядные операции
Поразрядные операции AND, OR и XOR (исключающее или) вроде бы должны работать, но и тут расширение со знаком путает все карты. Рассмотрим пример:
int flags = 0x7f;Вам кажется, что результатом операции должно быть 0xff, именно с этим значением вы и сравниваете, но настырный компилятор решает все сделать по–своему и приводит оба операнда к типу int. Вспомните, мы же говорили, что даже для поразрядных операций выполняется приведение к int, если операнды имеют более узкий тип. Поэтому flags расширяется до 0x0000007f, и тут ничего плохого нет, зато LowByte расширяется до 0xffffff80, в результате операции мы получаем 0xffffffff!
char LowByte = 0x80;
if ((char)flags ^ LowByte == 0xff)
return ItWorked;
Греховность С#
С# во многом похож на С++, что составляет его преимущество в случае, если вы знакомы с C/C++. Но это же и недостаток, так как для С# характерны многие из проблем, присущих С++. Один любопытный аспект С# заключается в том, что безопасность относительно типов проверяется гораздо строже, чем в C/C++. Например, следующий код не будет компилироваться:
Еще одна приятная особенность С# состоит в том, что он по мере необходимости пользуется 64–разрядными целыми числами. Например, следующий код дал бы неверный результат на С, но правильно работает на С#:
byte a, b;Если вы понимаете, о чем говорит это сообщение, то подумайте о возможных последствиях такого способа исправления ошибки:
a = 255;
b = 1;
byte c = (b + a);
error CS0029: Cannot implicitly convert type 'int' to 'byte'
(ошибка CS0029: Не могу неявно преобразовать тип 'int' в 'byte')
byte с = (byte) (Ь + а) ;Безопаснее воспользоваться классом Convert:
byte d = Convert.ToByte(a + b);Поняв, что пытается сказать компилятор, вы хотя бы задумаетесь, есть ли в вашем коде реальная проблема. К сожалению, возможности компилятора ограничены. Если бы в предыдущем примере вы избавились от ошибки, объявив a, b и с как целые со знаком, то появилась бы возможность переполнения, а компилятор ничего не сказал бы.
Еще одна приятная особенность С# состоит в том, что он по мере необходимости пользуется 64–разрядными целыми числами. Например, следующий код дал бы неверный результат на С, но правильно работает на С#:
int i = -1;Причина в том, что С# приведет операнды к типу long (64–разрядное целое со знаком), который позволяет точно сохранить оба числа. Если вы решите пойти дальше и проделать то же самое с числами типа long и ulong (в С# оба занимают 64 разряда), то компилятор сообщит, что необходимо явно преобразовать их к одному типу. По мнению авторов, стандарт C/C++ следует уточнить: если компилятор поддерживает операции над 64–разрядными значениями, то он должен в этом отношении вести себя так же, как С#.
uint j = 0xffffffff; // наибольшее положительное 32-разрядное целое
if(i == j)
Console.WriteLine("Отлично!");
Ключевые слова checked и unchecked
В языке С# есть ключевые слова checked и unchecked. Можно объявить checked–блок:
Слова checked и unchecked можно также использовать для включения или отключения контроля в одном выражении:
byte a = 1;В данном примере приведение а + b от int к byte возбуждает исключение. В следующей строке, где вызывается Convert.ToByte, исключение возникло бы и без ключевого слова checked, но его наличие приводит к возбуждению исключения еще и при вычислении аргументов метода Console.Write(). Поскольку иногда переполнение целого допускается намеренно, то имеется также ключевое слово unchecked, отключающее контроль на переполнение.
byte b = 255;
checked
{
byte c = (byte)(a + b);
byte d = Convert.ToByte(a + b);
Console.Write("{0} {1}\n", b+1, c);
}
Слова checked и unchecked можно также использовать для включения или отключения контроля в одном выражении:
checked (с = (byte) (Ь + а));И наконец, включить контроль можно с помощью флага /checked компилятора. Если этот флаг присутствует, то нужно явно помечать словом unchecked участки кода или отдельные предложения, в которых переполнение допустимо.
Греховность Visual Basic и Visual Basic .NET
Visual Basic регулярно претерпевает кардинальные модификации, а переход от Visual Basic 6.0 к Visual Basic .NET стал самым значительным шагом со времен введения объектной ориентированности в Visual Basic 3.0. Одно из самых фундаментальных изменений связано с целочисленными типами (см. табл. 3.1).
Вообще говоря, и Visual Basic 6.0, и Visual Basic .NET не подвержены угрозе исполнения произвольного кода из–за переполнения целых чисел. В Visual Basic 6.0 генерируется ошибка, если при выполнении какого–либо оператора или функции преобразования, например CInt(), возникает переполнение. В Visual Basic .NET в этом случае возбуждается исключение типа System.OverflowException. Как показано в табл. 3.1, программа на Visual Basic .NET имеет доступ ко всем целочисленным типам, определенным в каркасе .NET Framework.
Таблица 3.1. Целочисленные типы, поддерживаемые Visual Basic 6.0 и Visual Basic .NET
Хотя операции в самом языке Visual Basic, может быть, и неуязвимы для переполнения целого, но потенциальная проблема состоит в том, что вызовы Win32 API обычно принимают в качестве параметров 32–разрядные целые без знака (DWORD). Если ваша программа передает системному вызову 32–разрядное целое со знаком, то в ответ может получить отрицательное число. Аналогично вполне допустимо выполнить такую операцию, как 2 – 8046, над числами со знаком, но что, если указать в качестве одного из операндов такое число без знака, чтобы возникло переполнение? Если системный вызов возвращает некоторое значение, а потом вы выполняете манипуляции над этим значением и величиной, прямо или косвенно (после тех или иных вычислений) полученной от пользователя, и напоследок обращаетесь к другим системным вызовам, то можете оказаться в угрожаемой ситуации. Переходы от знаковых чисел к беззнаковым и обратно чреваты опасностью. Даже если переполнение целого и не приведет к исполнению произвольного кода, необработанные исключения станут причиной отказа от обслуживания. Неработающее приложение не приносит доходов заказчику.
Вообще говоря, и Visual Basic 6.0, и Visual Basic .NET не подвержены угрозе исполнения произвольного кода из–за переполнения целых чисел. В Visual Basic 6.0 генерируется ошибка, если при выполнении какого–либо оператора или функции преобразования, например CInt(), возникает переполнение. В Visual Basic .NET в этом случае возбуждается исключение типа System.OverflowException. Как показано в табл. 3.1, программа на Visual Basic .NET имеет доступ ко всем целочисленным типам, определенным в каркасе .NET Framework.
Таблица 3.1. Целочисленные типы, поддерживаемые Visual Basic 6.0 и Visual Basic .NET
Хотя операции в самом языке Visual Basic, может быть, и неуязвимы для переполнения целого, но потенциальная проблема состоит в том, что вызовы Win32 API обычно принимают в качестве параметров 32–разрядные целые без знака (DWORD). Если ваша программа передает системному вызову 32–разрядное целое со знаком, то в ответ может получить отрицательное число. Аналогично вполне допустимо выполнить такую операцию, как 2 – 8046, над числами со знаком, но что, если указать в качестве одного из операндов такое число без знака, чтобы возникло переполнение? Если системный вызов возвращает некоторое значение, а потом вы выполняете манипуляции над этим значением и величиной, прямо или косвенно (после тех или иных вычислений) полученной от пользователя, и напоследок обращаетесь к другим системным вызовам, то можете оказаться в угрожаемой ситуации. Переходы от знаковых чисел к беззнаковым и обратно чреваты опасностью. Даже если переполнение целого и не приведет к исполнению произвольного кода, необработанные исключения станут причиной отказа от обслуживания. Неработающее приложение не приносит доходов заказчику.
Греховность Java
В отличие от Visual Basic и С#, в язык Java не встроена защита от переполнений. Вот цитата из спецификации языка «Java Language Specification*, размещенной по адресу http://java.sun.com/docs/books/jls/second_edition/html/typeValues.doc.html#9151:
При выполнении встроенных операций над целыми типами переполнение или потеря значимости не индицируются. Единственные арифметические операторы, которые могут возбудить исключение (§11), – это оператор целочисленного деления / (§15.17.2) и оператор вычисления остатка % (§15.17.3). Они возбуждают исключение ArithmeticException, если правый операнд равен нулю.
В отличие от Visual Basic, Java поддерживает лишь подмножество всего диапазона целочисленных типов. Хотя 64–разрядные целые поддерживаются, но единственным беззнаковым типом является char, и он представляется в виде 16–разрядного значения без знака.
Поскольку в Java есть только знаковые типы, проверка переполнения становится непростым делом, и по сравнению с C/C++ удается лишь избежать затруднений, связанных со смешанными операциями над знаковыми и беззнаковыми величинами.
При выполнении встроенных операций над целыми типами переполнение или потеря значимости не индицируются. Единственные арифметические операторы, которые могут возбудить исключение (§11), – это оператор целочисленного деления / (§15.17.2) и оператор вычисления остатка % (§15.17.3). Они возбуждают исключение ArithmeticException, если правый операнд равен нулю.
В отличие от Visual Basic, Java поддерживает лишь подмножество всего диапазона целочисленных типов. Хотя 64–разрядные целые поддерживаются, но единственным беззнаковым типом является char, и он представляется в виде 16–разрядного значения без знака.
Поскольку в Java есть только знаковые типы, проверка переполнения становится непростым делом, и по сравнению с C/C++ удается лишь избежать затруднений, связанных со смешанными операциями над знаковыми и беззнаковыми величинами.
Греховность Perl
По крайней мере, два автора этой книги являются горячими сторонниками Perl. Но, несмотря на это, следует признать, что работа с целыми числами реализована в Perl странно. Внутри они представляются в виде чисел с плавающей точкой двойной точности, но тестирование позволяет выявить некоторые любопытные вещи. Рассмотрим следующий код:
В силу особенностей работы с числами в Perl мы рекомендуем быть очень осторожными при написании на этом языке приложений, в которых много математических операций. Если вы не разбираетесь досконально в арифметике с плавающей точкой, то можете столкнуться с весьма поучительными сюрпризами. Другие языки высокого уровня, например Visual Basic, тоже иногда производят преобразования в числа с плавающей точкой. Следующий код иллюстрирует, что в таком случае происходит:
$h = 4294967295;В результате печатается следующее:
$i = 0xffffffff;
$k = 0x80000000;
print "$h = 4294967295 – $h + 1 = ".($h + 1)."\n";
print "$i = 0xffffffff – $i + 1 = ".($i + 1)."\n";
printf("\nИспользуется printf со спецификатором %%d\n");
printf("\\$i = %d, \$i + 1 = %d\n\n", $i, $i + 1);
printf("\nТестируется граничный случай деления\n");
printf("0x80000000/-1 = %d\n", $k/-1);
print "0x80000000/-1 = ".($k/-1)."\n";
[e:\projects\19_sins\perl foo.plНа первый взгляд, результат выглядит странно, особенно когда используется printf с форматной строкой (в отличие от обычной функции print). Первым делом в глаза бросается то, что мы можем присвоить переменной максимально возможное значение без знака, но после прибавления к нему 1 она либо увеличивается на единицу, либо – если печатать с помощью %d – вообще не изменяется. Загвоздка в том, что на самом деле вы оперируете числами с плавающей точкой, а спецификатор %d заставляет Perl преобразовать double в int. В действительности никакого переполнения нет, но при печати результатов создается впечатление, будто оно произошло.
4294967295 = 4294967295 – 4294967295 + 1 = 4294967296
4294967295 = 0xffffffff – 4294967295 + 1 = 4294967296
Используется printf со спецификатором %d
$i = -1, $i + 1 = -1
Тестируется граничный случай деления
0x80000000/-1 = -2147483648
0x80000000/-1 = -2147483648
В силу особенностей работы с числами в Perl мы рекомендуем быть очень осторожными при написании на этом языке приложений, в которых много математических операций. Если вы не разбираетесь досконально в арифметике с плавающей точкой, то можете столкнуться с весьма поучительными сюрпризами. Другие языки высокого уровня, например Visual Basic, тоже иногда производят преобразования в числа с плавающей точкой. Следующий код иллюстрирует, что в таком случае происходит:
print (5/4)."\n";Для большинства обычных приложений Perl ведет себя предсказуемо и работает великолепно. Но не забывайте, что вы имеете дело не с целыми числами, а с числами с плавающей точкой, – а это «две большие разницы».
1.25
Где искать ошибку
Любое приложение, в котором производятся арифметические операции, подвержено этому греху, особенно когда некоторые входные данные поступают от пользователя и их правильность не проверяется. Особое внимание обращайте на вычисление индексов массивов и размеров выделяемых буферов в программах на C/C++.
Выявление ошибки на этапе анализа кода
При написании программ на языках C/C++ надо обращать самое пристальное внимание на возможность переполнения целого числа. Теперь, когда многие разработчики осознали важность проверок размеров при прямых манипуляциях с памятью, атаки направлены нате арифметические операции, с помощью которых эти проверки выполняются. Следующими на очереди стоят С# и Java. Прямые манипуляции с памятью в этих языках запрещены, но тем не менее можно допустить почти все ошибки, которые характерны для С и С++.
Ко всем языкам относится следующее замечание: проверяйте входные данные прежде, чем каким–либо образом их использовать! В Web–серверах Microsoft IIS 4.0 и 5.0 очень серьезная ошибка имела место из–за того, что программист сначала прибавил к переменной 1, а затем проверил, не оказался ли размер слишком большим. При тех типах, что он использовал, 64К–1 + 1 оказалось равно нулю! На соответствующее извещение есть ссылка в разделе «Другие ресурсы».
Ко всем языкам относится следующее замечание: проверяйте входные данные прежде, чем каким–либо образом их использовать! В Web–серверах Microsoft IIS 4.0 и 5.0 очень серьезная ошибка имела место из–за того, что программист сначала прибавил к переменной 1, а затем проверил, не оказался ли размер слишком большим. При тех типах, что он использовал, 64К–1 + 1 оказалось равно нулю! На соответствующее извещение есть ссылка в разделе «Другие ресурсы».
C/C++
Первым делом найдите все места, где выделяется память. Самое опасное – это выделение блока, размер которого вычисляется. Убедитесь, что при этом невозможно переполнение целого. Далее обратите внимание на функции, которые получают входные данные. Автор как–то встретил примерно такой код:
По мнению автора языка Perl, величайшим достоинством программиста является лень! Так давайте пойдем простым путем – заставим потрудиться компилятор. Включите уровень диагностики /W4 (для Visual С++) или–Wall либо–Wsign–compare (для gcc) – и вы увидите, как много в вашей программе мест, где возможны проблемы с целыми числами. Обращайте внимание на все предупреждения, касающиеся целых чисел, особенно на те, в которых говорится о сравнении знаковых и беззнаковых величин, а также об усечении.
В Visual С++ самыми важными с этой точки зрения являются предупреждения С4018, С4389иС4244.
В gcc ищите предупреждения «warning: comparison between signed and unsigned integer expressions».
Относитесь с подозрением к директивам #pragma отключающим предупреждения, например:
Не вздумайте подавлять эти надоедливые предупреждения путем приведения типов. Теперь вы знаете, насколько это рискованно и как внимательно нужно все проверять. Найдите все приведения и убедитесь, что они безопасны. О приведениях и преобразованиях в языках С и С++ см. раздел «Операции приведения» выше.
Вот еще один пример:
Еще один источник неприятностей – это оператор new в языке С++. Он сопряжен с неявным умножением:
THING* AllocThings(int a, int b, int c, int d)Ошибка скрывалась в функции, вычисляющей размер буфера, к тому же найти ее мешали загадочные, ничего не говорящие читателю имена переменных (и литералов, которые представлены типом signed int). Если у вас есть время на доскональный анализ, проследите порядок вызова всех ваших функций вплоть до обращений к низкоуровневым библиотечным функциям или системным вызовам. И напоследок выясните, откуда поступают данные. Можете ли вы утверждать, что аргументы функций не подвергались манипуляциям? Кто контролирует аргументы: вы или потенциальный противник?
{
int bufsize;
THING* ptr;
bufsize = IntegerOverflowsRUs(a, b, c, d);
ptr = (THING*)malloc(bufsize);
return ptr;
}
По мнению автора языка Perl, величайшим достоинством программиста является лень! Так давайте пойдем простым путем – заставим потрудиться компилятор. Включите уровень диагностики /W4 (для Visual С++) или–Wall либо–Wsign–compare (для gcc) – и вы увидите, как много в вашей программе мест, где возможны проблемы с целыми числами. Обращайте внимание на все предупреждения, касающиеся целых чисел, особенно на те, в которых говорится о сравнении знаковых и беззнаковых величин, а также об усечении.
В Visual С++ самыми важными с этой точки зрения являются предупреждения С4018, С4389иС4244.
В gcc ищите предупреждения «warning: comparison between signed and unsigned integer expressions».
Относитесь с подозрением к директивам #pragma отключающим предупреждения, например:
#pragma warning(disable : 4244)Во вторую очередь следует искать места, где вы пытаетесь защититься от переполнения буфера (в стеке или в куче) путем проверки выхода за границы. Убедитесь, что все арифметические вычисления корректны. В следующем примере показано, как может возникнуть ошибка:
int ConcatBuffers(char *buf1, char *buf2,Здесь проверяется, что суммарный размер двух входных буферов не превышает размера выходного буфера. Но если lenl равно 0x103, а 1еп2 равно 0xfffffffc, то сумма переполняет 32–разрядный регистр процессора и оказывается равной 255 (0xff), так что проверка успешно проходит. В результате memcpy попытается записать примерно 4 Гб в буфер размером всего 255 байтов!
size_t len1, size_t len2) {
char buf[0xFF];
if(len1 + len2) > 0xFF) return -1;
memcpy(buf, buf1, len1);
memcpy(buf + len1, buf2, len2);
// сделать что-то с buf
return 0;
}
Не вздумайте подавлять эти надоедливые предупреждения путем приведения типов. Теперь вы знаете, насколько это рискованно и как внимательно нужно все проверять. Найдите все приведения и убедитесь, что они безопасны. О приведениях и преобразованиях в языках С и С++ см. раздел «Операции приведения» выше.
Вот еще один пример:
int read(char* buf, size_t count) {В этом фрагменте значение skip сравнивается с 1024, и если оно меньше, то в буфер buf копируется skip байтов. Проблема в том, что если skip оказывается отрицательным (скажем, -2), то оно будет заведомо меньше 1024, так что функция read попытается скопировать–2 байта, а это значение, будучи представлено как целое без знака (тип size_t), равно без малого 4 Гб. Итак, read() копирует 4 Гб в буфер размером 1 Кб. Печально!
// Сделать что-то с памятью
}
...
while (true) {
BYTE buf[1024];
int skip = count – cbBytesRead;
if (skip > sizeof(buf))
skip = sizeof(buf);
if (read(buf, skip))
cbBytesRead += skip;
else
break;
...
Еще один источник неприятностей – это оператор new в языке С++. Он сопряжен с неявным умножением:
Foo *p = new Foo(N);Если N контролируется противником, то возможно переполнение внутри operator new при вычислении выражения N * sizeof(Foo);
С#
Хотя сам язык С# и не допускает прямых обращений к памяти, но иногда программа обращается к системным вызовам, помещенным в блок, помеченный ключевым словом unsafe (при этом ее еще надо компилировать с флагом /unsafe). Любые вычисления, результат которых передается системному вызову, нужно контролировать. Для этого полезно применить ключевое слово checked или – еще лучше – соответствующий флаг компилятора. Включите его и следите, не произойдет ли исключения. Напротив, ключевое слово unchecked используйте изредка, предварительно обдумав все последствия.