понедельник, 1 декабря 2008 г.

Почему размеры записей проверяются строго?

Это перевод Why are structure sizes checked strictly? Автор: Реймонд Чен.

Вы могли заметить, что в Windows строгая проверка размеров записей является обычным делом.

Например, рассмотрим запись TMenuItemInfo (*):
type
  tagMENUITEMINFOA = record
    cbSize: UINT;
    fMask: UINT;
    fType: UINT;            // используется, если MIIM_TYPE (4.0) или MIIM_FTYPE (>4.0)
    fState: UINT;           // используется, если MIIM_STATE
    wID: UINT;              // используется, если MIIM_ID
    hSubMenu: HMENU;        // используется, если MIIM_SUBMENU
    hbmpChecked: HBITMAP;   // используется, если MIIM_CHECKMARKS
    hbmpUnchecked: HBITMAP; // используется, если MIIM_CHECKMARKS
    dwItemData: ULONG_PTR;  // используется, если MIIM_DATA
    dwTypeData: LPSTR;      // используется, если MIIM_TYPE (4.0) или MIIM_STRING (>4.0)
    cch: UINT;              // используется, если MIIM_TYPE (4.0) или MIIM_STRING (>4.0)
    {$IFDEF WIN98ME_UP}
    hbmpItem: HBITMAP;      // доступно только в Windows 2000 и выше
    {$ENDIF WIN98ME_UP}
  end;
Заметьте, что размер этой структуры зависит от того, определена ли директива WIN98ME_UP (т.е., в зависимости от того, нацеливаетесь ли вы на Windows 2000 или выше). Если вы возьмёте версию записи для Windows 2000 и передадите её на Windows NT 4, то вызов функции будет неуспешным, потому что размер не совпадает с ожидаемым.
"Но старые версии операционных систем должны принимать любой размер, который больше или равен размеру, который они ожидают. Больший размер означает, что запись пришла от более новой версии программы, и ОС должна просто игнорировать части, которые она не понимает."
Мы пробовали так. И это не стало работать.

Рассмотрим следующую воображаемую запись и функцию, в которую она передаётся. Мы используем их как подопытных кроликов для дальнейшего обсуждения:
type
  tagIMAGINARY = record
    cbSize: UINT;
    fDance: BOOL;                  // Танцевать
    fSing: BOOL;                   // Петь
    {$IFDEF IMAGINARY_VERSION_2_UP}
    // Новые фишки, добавленные в v2
    psp: IServiceProvider;         // Где искать больше информации
    {$ENDIF}
  end;
  TImaginary = tagIMAGINARY;

// выполнить действия, которые мы указали
procedure DoImaginaryThing(const pimg: TImaginary); stdcall;

// запросить, какие действия сейчас выполняются
procedure GetImaginaryThing(var pimg: TImaginary); stdcall;
Сначала мы обнаружим, что куча программ вообще просто забывают инициализировать поле cbSize.
var
  img: TImaginary;
...
  img.fDance := True;
  img.fSing := False;
  DoImaginaryThing(img);
Поэтому вместо размера записи у них там лежит мусор со стека. Мусор со стека часто бывает большими числами, поэтому он проходит проверку "больше или равен ожидаемому размеру cbSize" и код работает. Тогда новая версия заголовочного файла расширяет запись, используя cbSize для определения: использует ли вызывающий новую или старую версию. Теперь, мусор из стека всё ещё больше или равен новому cbSize, поэтому версия 2 функции DoImaginaryThing говорит: "О, клёво, тут кто-то хочет предоставить нам дополнительную информацию через поле IServiceProvider". За исключением, конечно, того, что там лежит мусор из стека, поэтому вызов метода IServiceProvider.QueryService приведёт к вылету.

Теперь рассмотрим и другой возможный сценарий:
var
  img: TImaginary;
...
  GetImaginaryThing(img);
Новая версия заголовочного файла расширяет запись, а мусор в стеке по-прежнему большое число и проходит проверку "больше или равен ожидаемому размеру cbSize", поэтому функция возвращает не только поля fDance и fSing, но и psp. Упс, но ведь вызывающий был скомпилирован с записью версии v1, поэтому у его записи нет никакого поля psp. Поле psp записывается за концом записи, перезаписывая любые данные, лежащие там. Ох, теперь у нас одна из этих ужасных проблем переполнения буфера.

Даже, если вам повезло и память после записи можно попортить, у вас всё ещё есть баг: по правилам учёта ссылок COM, когда функция возвращает интерфейс, вызывающая сторона ответственна за освобождение интерфейса, когда он более не нужен. Но вызывающая сторона работает с v1 и ничего не знает об этом поле psp, поэтому, конечно же, она не знает, что нужно вызывать psp := nil (в Delphi это приводит к psp._Release). Поэтому теперь в дополнении к порче памяти (как будто одного этого нам было недостаточно), у вас также появляется утечка памяти.

Постойте, я ещё не закончил. Теперь давайте посмотрим, что произойдёт, если новая версия программы работает на старой системе.

Предположим, что кто-то пишет программу, предназначенную для работы на системах с версией v2. Он устанавливает cbSize равный большему размеру записи v2 и устанавливает поле psp на поставщика услуг, который производит проверку безопасности перед тем, как разрешать кому-то танцевать или петь (к примеру, проверяет, что все заплатили за вход). Теперь другой человек берёт эту программу и запускает на системе версии v1. Размер записи нового формата v2, конечно же, проходит проверку "больше или равен размеру записи v1", поэтому система v1 примет эту запись и "Выполнит Воображаемое Действие" ("Do the Imaginary Thing"). За исключением того, что v1 не поддерживает поле psp, поэтому ваш провайдер ни разу не будет вызван и вся ваша система безопасности окажется не у дел. Теперь все ходят в ваш клуб, не платя входную плату.

Теперь, вы можете сказать: "ну, это всё багнутые программы. Это их вина". Если вы придерживаетесь такой логики, тогда не удивляйтесь, когда как грибы после дождя начнут появляться журнальные статьи вроде "Microsoft намеренно сделала <Продукт X> несовместимым с <программа от конкурента>. Где же министерство юстиции (Justice Department), когда оно так нужно?".

Примечание переводчика: (*) определение структуры взято из заголовочников JEDI. В самой Delphi обычно участвует всего один вариант структуры (он может быть разным в зависимости от версии Delphi).

Комментариев нет:

Отправить комментарий

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.