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

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. Вказівник зникає, лічильник посилань, який мав би зменшитися спочатку, ніколи не торкається, а блок, на який він вказував, сидить виділеним без нічого, що на нього більше посилається

Діаграма: FillChar обнуляє байти вказівника Description у записі Delphi, тоді як його блок купи тримає refcount 1 і тече
Байти вказівника штампуються нулем, тоді як лічений купівельний блок лишається виділеним і недосяжним

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

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

type
  TLineItem = record
    Description: string;  // керований: з лічильником посилань
    Quantity: Integer;    // некерований: звичайний порядковий тип
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // очищає байти, а не посилання
  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);  // зі другого проходу: виток попереднього Description
    Log.Add(Item.Description);
  end;
end;

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

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

Діаграма порівняння: безпечний перший виклик і той, що тече, — другий виклик циклу Delphi, де 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);   // очищає байти, а не живе посилання
  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);   // виправлено: було 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 був, і залишається, правильним інструментом

Діаграма рішень PDFium Component: чищення записів Object Pascal через FillChar або Default залежно від керованих рядкових і масивних полів на будь-якій глибині вкладеності
Записи без жодного керованого поля на будь-якій глибині можуть лишати FillChar, все інше очищається через Default

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

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