Технічна стаття

FillChar на результаті функції витікає рядки в Object Pascal

Функція Delphi чи FPC, що повертає запис, не отримує свіжий, обнулений Result при кожному виклику. Ця прихована змінна Result починається з нуля рівно один раз, і ніщо не обнулює її автоматично знову між викликами, тож очищення її на вході — власна робота функції. Виконайте це очищення через FillChar(Result, SizeOf(Result), 0), і, починаючи з другого виклику, процедура перезаписує живе посилання на рядок чи динамічний масив замість того, щоб звільнити його, залишаючи сиротою той блок купи, на який вказувало це посилання

Сценарій, у якому це кусає, буденний. Пакетний процес відкриває стос сторонніх PDF і обходить кожну анотацію на кожній сторінці, витягуючи текст коментаря в журнал аудиту. Ніщо в цьому циклі не виглядає небезпечним: кожен виклик — звичайна функція, що повертає звичайний запис, жодних вказівників у полі зору, нічого, що нагадувало б ручне керування пам'яттю. Підрахунок посилань усередині запису — звичайне правило обліку Object Pascal, не примха, специфічна для якоїсь конкретної бібліотеки, і будь-яка кодова база Delphi чи FPC, що змішує FillChar із типами записів, які тримають рядки чи динамічні масиви, вразлива до того самого дефекту

Чому FillChar на результаті-записі витікає рядки?

FillChar витікає рядки, бо не має жодного уявлення, який тип даних перезаписує. FillChar(X, Count, Value) працює на будь-якій змінній взагалі: він бере нетипізований блок з Count байтів і проштамповує кожен із них значенням Value, і це весь контракт. Це саме те, що робить FillChar швидким і універсальним, бо він ніколи не перевіряє тип X і ніколи не розгалужується залежно від того, що означають базові байти. Поле UnicodeString чи WideString усередині запису — не самі символи; це вказівник на блок купи, що несе лічильник посилань перед даними символів. FillChar бачить жменю байтів, що випадково тримають значення вказівника, і перезаписує їх нулем точно так само, як перезаписав би поле Integer чи Double. Вказівник зникає, лічильник посилань, який мав би зменшитися спочатку, ніколи не торкається, а блок, на який він вказував, сидить виділеним без нічого, що на нього більше посилається

Як компілятор відстежує рядки та динамічні масиви всередині запису

Object Pascal називає тип керованим, коли компілятору доводиться виконувати додатковий код, щоб зберегти його коректність через присвоєння та вихід з області видимості. Довгі типи рядків, такі як AnsiString, UnicodeString та WideString, підходять під це, як і динамічні масиви, інтерфейси та Variant, разом із будь-яким записом чи масивом фіксованого розміру, що містить один із них як поле. Для кожного керованого поля компілятор тихо генерує облік, який інакше був би нудним і легким для помилки вручну: збільшити лічильник посилань при присвоєнні, зменшити його, коли змінна, що тримає, перезаписується чи виходить із області видимості, і звільнити базовий блок, щойно цей лічильник сягає нуля. Саме цей механізм — причина того, чому звичайний код Pascal ніколи не виділяє чи не звільняє string вручну, і чому присвоєння одного динамічного масиву іншому — дешева, безпечна операція, а не цикл ручного копіювання. System.Default та Finalize — два задокументовані способи викликати ту саму логіку звільнення на вимогу, і саме їх код очищення запису мусить викликати замість сирого заповнення пам'яті

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

Чому витік починається лише з другого виклику?

Перший виклик у циклі завжди безпечний, і саме це робить цей дефект легким для пропуску під час тестування. Локальна змінна керованого типу запису починається з нуля, і ніщо не обнулює її автоматично знову між одним проходом циклу й наступним, тож коли цикл вперше присвоює значення, що повертає функція, у цю змінну, її поле Description чи ContentsText усе ще nil. FillChar перезаписує nil нулем, що нічого не змінює щодо лічильника посилань, і виклик повертається виглядаючи цілком коректним. Другий виклик інший: та сама локальна змінна вже тримає те, що записав туди перший виклик, і Result нового виклику записується прямо в те саме сховище, а не у свіжу, порожню пам'ять. FillChar на початку цього другого виклику обнулює поле, що вже не nil, і усе нижче за течією від цього шаблону байтів мовчки неправильне з цього моменту. Тест, що викликає функцію один раз і перевіряє результат, ніколи не побачить проблеми; лише цикл чи будь-який шлях коду, що викликає функцію повторно проти того самого призначення, викриває це

Реальний витік: анотації, закладки та записи посилань

PDFiumPas постачав саме цей дефект до версії 1.56.4, у трьох функціях, кожна з яких повертає запис, що тримає щонайменше одне керуване поле: читач анотацій на рівні сторінки повертає TPdfAnnotation, що несе рядки ContentsText та AuthorText, читач закладок повертає TBookmark, що несе рядок Title, а читач анотацій-посилань повертає TLinkAnnotation, що несе рядок ActionPath та динамічний масив Points. Усі три відкривалися тією самою формою, показаною нижче: очистити Result сирим FillChar, потім заповнювати поля по одному з базових даних сторінки. Обхід кожної анотації на сторінці по одній, звичайний спосіб побудувати список аудиту чи панель рецензування, викликав читач анотацій у циклі й витікав текст попередньої анотації на кожному проході після першого; PDF, створений з незвично великою кількістю анотацій, що несуть текст, міг збільшувати пам'ять довготривалого процесу, доки цей процес продовжував виконуватися. Виправлення торкнулося одного рядка в кожній функції: заміна FillChar(Result, SizeOf(Result), 0) на Result := Default(TPdfAnnotation) була достатньою, бо присвоєння Default керованому запису запускає звичайну послідовність компілятора «звільнити-потім-очистити» замість сирого заповнення пам'яті

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

Та сама небезпека за параметром var

Читач закладок показує тонший варіант тієї самої проблеми, бо запис, що очищається FillChar, — не власний Result функції, а параметр var на виклик глибше. SetBookmarkData приймає свій вихід як var Data: TBookmark і раніше очищав Data на початку свого тіла FillChar; GetBookmark, публічна функція, що фактично повертає TBookmark, викликає SetBookmarkData і передає власний Result прямо як цей аргумент var. Параметр var передається за посиланням, тож Data усередині SetBookmarkData та Result усередині GetBookmark — те саме сховище під двома іменами, і будь-який ризик псевдонімності, що застосовується до власного Result функції, застосовується так само прямо до будь-якої допоміжної процедури, що отримує його за посиланням. Перегляд лише функцій, що буквально оголошують тип повернення запису, пропускає цю форму; пошук мусить також простежувати кожен параметр var та out, у який переспрямовується Result

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

Коли FillChar усе ще правильний вибір?

FillChar усе ще коректний, і часто дещо дешевший, для запису, побудованого повністю з ординальних, з рухомою комою полів, масивів фіксованого розміру з них, чи інших простих записів, зроблених із того самого, бо в ньому немає нічого для компілятора, що потребувало б завершення. Власний тип прямокутника PDFiumPas — точно такий випадок: TPdfRectangle тримає чотири поля Double і нічого більше, і очищення одного з них FillChar нічого не звільняє, бо звільняти нічого з підрахунком посилань. Перевірка, що розділяє два випадки, проста для формулювання: чи має якесь поле запису, на будь-якій глибині вкладеності, тип string, AnsiString, WideString, динамічний масив, інтерфейс чи Variant? Запис може виглядати цілком числовим на верхньому рівні й усе одно провалити цю перевірку, якщо одне з його полів само є записом, що ховає рядок на кілька рівнів глибше, тож перевірка мусить простежувати вкладені записи наскрізь, а не зупинятися на списку полів найзовнішнього рівня. Аудит наявної кодової бази на цей шаблон механічний, а не вичерпний: шукайте кожен виклик FillChar, чия ціль — змінна запису, потім перевіряйте список полів цього запису проти наведеного вище списку керованих типів. Власний аудит PDFiumPas версії v1.56.4 виконав саме цей пошук по всій бібліотеці й знайшов цю вразливість в одному модулі; кожне інше місце виклику FillChar уже очищало звичайний числовий запис, де FillChar був, і залишається, правильним інструментом

Та сама поведінка компілятора, що робить повторно використаний Result небезпечним тут, також рухає споріднену родину розбіжностей Delphi проти FPC деінде в цій кодовій базі; супутня стаття про підводні камені крос-компілятора розглядає випадок, де FPC та Delphi розходяться в думках щодо того, коли саме завершується тимчасова змінна результату-запису всередині одного виразу, інший симптом того самого базового факту, що Result-запис функції не завжди є тим свіжим, приватним сховищем, яким здається. Цикл анотацій, використаний як наскрізний приклад у цій статті, теж не гіпотетичний: це той самий обхід сторінка-за-сторінкою, який ви написали б, будуючи панель рецензування анотацій, а це саме та форма коду, що спершу перетворила однорядковий FillChar на повільний витік пам'яті

Ніщо з цього не вимагає зміни бібліотек чи полювання на помилку в чужому скомпільованому коді: це властивість самої мови Object Pascal, з якою щодня працює кожен розробник Delphi та FPC, і виправлення — один виклик функції, щойно знаєш, що шукати. API анотацій, закладок та анотацій-посилань, описані тут, постачаються як частина компонента PDFium для Delphi, C++Builder та Lazarus/FPC, поряд з рештою поверхні читання, рендерингу та анотацій PDF, розглянутої деінде в цьому блозі