기술 문서

Object Pascal에서 함수 결과에 FillChar를 쓰면 문자열이 유출되는 이유

Delphi나 FPC 함수가 레코드를 반환할 때, 호출마다 새로 초기화된 0으로 채워진 Result를 받는 것이 아니다. 이 숨겨진 Result 변수는 정확히 한 번만 0에서 시작하며, 호출 사이에 그것을 자동으로 다시 0으로 만들어주는 것은 아무것도 없다. 그래서 이를 초기화하는 것은 함수 자신의 몫이다. 이 초기화를 FillChar(Result, SizeOf(Result), 0)로 하면, 두 번째 호출부터는 이 루틴이 살아있는 문자열이나 동적 배열 참조를 해제하는 대신 덮어써버려, 그 참조가 가리키던 힙 블록을 고아로 만든다

이것이 물어뜯는 시나리오는 평범하다. 배치 프로세스가 서드파티 PDF 더미를 열어 모든 페이지의 모든 주석을 순회하며 코멘트 텍스트를 감사 로그로 뽑아낸다. 그 루프의 어느 것도 위험해 보이지 않는다: 모든 호출은 그저 레코드를 반환하는 평범한 함수이고, 포인터도 보이지 않으며, 수동 메모리 관리를 닮은 것도 전혀 없다. 레코드 안의 참조 카운팅은 특정 라이브러리에 고유한 특이 동작이 아니라 평범한 Object Pascal 회계 규칙이며, FillChar를 문자열이나 동적 배열을 담은 레코드 타입과 함께 섞어 쓰는 어떤 Delphi나 FPC 코드베이스든 같은 결함에 노출된다

레코드 Result에 대한 FillChar는 왜 문자열을 유출시키는가?

FillChar가 문자열을 유출시키는 이유는 자신이 덮어쓰는 데이터가 어떤 종류인지 전혀 모르기 때문이다. FillChar(X, Count, Value)는 어떤 변수에서든 동작한다: 타입이 없는 Count 바이트 블록을 받아 그 하나하나에 Value를 찍는데, 그것이 계약의 전부다. 이것이 바로 FillChar를 빠르고 범용적으로 만드는 것인데, X의 타입을 절대 검사하지 않고 밑에 있는 바이트가 무엇을 의미하는지에 따라 절대 분기하지 않기 때문이다. 레코드 안의 UnicodeString이나 WideString 필드는 문자 자체가 아니다; 이는 문자 데이터 앞에 참조 카운트를 담은 힙 블록으로의 포인터다. FillChar는 우연히 포인터 값을 담고 있는 몇 바이트를 보고, IntegerDouble 필드를 덮어쓸 때와 정확히 똑같이 그것을 0으로 덮어쓴다. 포인터는 사라지고, 먼저 감소되었어야 할 참조 카운트는 전혀 건드려지지 않으며, 그것이 가리키던 블록은 이제 아무것도 참조하지 않는 채로 할당된 상태로 남는다

컴파일러는 레코드 안의 문자열과 동적 배열을 어떻게 추적하는가

Object Pascal은 컴파일러가 대입과 스코프 종료를 거쳐 올바름을 유지하기 위해 추가 코드를 실행해야 하는 타입을 관리형(managed)이라고 부른다. AnsiString, UnicodeString, WideString 같은 긴 문자열 타입이 여기 해당하고, 동적 배열, 인터페이스, Variant도 마찬가지이며, 이들 중 하나를 필드로 담은 레코드나 고정 배열도 마찬가지다. 관리형 필드마다 컴파일러는 손으로 하면 지루하고 틀리기 쉬운 회계 작업을 조용히 만들어낸다: 대입 시 참조 카운트를 증가시키고, 그것을 담은 변수가 덮어써지거나 스코프를 벗어날 때 감소시키며, 그 카운트가 0에 도달하면 밑에 있는 블록을 해제한다. 이 메커니즘이 평범한 Pascal 코드가 string을 수동으로 할당하거나 해제하지 않는 이유이며, 동적 배열 하나를 다른 것에 대입하는 것이 수동 복사 루프가 아니라 저렴하고 안전한 작업인 이유다. System.DefaultFinalize는 그 같은 해제 로직을 필요할 때 호출하는 두 가지 문서화된 방법이며, 레코드의 초기화 코드가 원시 메모리 채우기 대신 호출해야 할 것이 바로 이것이다

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;

유출은 왜 두 번째 호출부터 시작하는가?

루프 안의 첫 호출은 항상 무해한데, 이것이 정확히 이 결함을 테스트에서 놓치기 쉽게 만든다. 관리형 레코드 타입의 로컬 변수는 0에서 시작하며, 한 루프 패스와 다음 패스 사이에 그것을 자동으로 다시 0으로 만들어주는 것은 아무것도 없다. 그래서 루프가 처음으로 함수의 반환값을 그 변수에 대입할 때, 그 Description이나 ContentsText 필드는 여전히 nil이다. FillChar는 nil을 0으로 덮어쓰는데, 참조 카운트 입장에서는 아무것도 바뀌지 않으며, 호출은 완전히 올바르게 보이는 채로 반환된다. 두 번째 호출은 다르다: 같은 로컬 변수는 이미 첫 호출이 그 안에 써넣은 것을 담고 있고, 새 호출의 Result는 새롭고 빈 메모리가 아니라 바로 그 같은 저장소에 쓰인다. 그 두 번째 호출 맨 위의 FillChar는 더 이상 nil이 아닌 필드를 0으로 만들고, 그 바이트 패턴 아래의 모든 것은 그때부터 조용히 잘못되어 있다. 함수를 한 번만 호출하고 결과를 검사하는 테스트는 이 문제를 절대 보지 못한다; 오직 루프, 혹은 같은 대상에 대해 함수를 반복 호출하는 어떤 코드 경로만이 이를 드러낸다

실제 유출: 주석, 북마크, 링크 레코드

PDFiumPas는 버전 1.56.4 이전에 정확히 이 결함을 가지고 있었다. 각각 최소 하나의 관리형 필드를 담은 레코드를 반환하는 세 함수에서였다: 페이지 수준 주석 리더는 ContentsTextAuthorText 문자열을 담은 TPdfAnnotation을 반환하고, 북마크 리더는 Title 문자열을 담은 TBookmark를 반환하며, 링크 주석 리더는 ActionPath 문자열과 Points 동적 배열을 담은 TLinkAnnotation을 반환한다. 셋 다 아래에 보이는 것과 같은 형태로 시작했다: 원시 FillCharResult를 지운 다음, 밑에 있는 페이지 데이터로부터 필드를 하나씩 채운다. 감사 목록이나 검토 패널을 만드는 일반적인 방법인, 페이지의 모든 주석을 하나씩 순회하는 작업은 루프 안에서 주석 리더를 호출했고, 첫 패스 이후 매 패스마다 이전 주석의 텍스트를 유출시켰다; 텍스트를 담은 주석이 유난히 많이 만들어진 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로 받으며 예전에는 그 본문 맨 위에서 FillChar로 Data를 지웠다; 실제로 TBookmark를 반환하는 공개 함수인 GetBookmarkSetBookmarkData를 호출하며 자신의 Result를 그 var 인자로 곧바로 전달한다. var 매개변수는 참조로 전달되므로, SetBookmarkData 안의 DataGetBookmark 안의 Result는 두 개의 이름을 가진 같은 저장소이며, 함수 자신의 Result에 적용되는 어떤 별칭 위험이든 그것을 참조로 받는 어떤 헬퍼 루틴에도 똑같이 직접 적용된다. 레코드 반환 타입을 문자 그대로 선언하는 함수만 검토하면 이 형태를 놓친다; 검색은 Result가 전달되는 모든 varout 매개변수도 따라가야 한다

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는 이 블로그 다른 곳에서 다루는 나머지 PDF 읽기·렌더링·주석 표면과 함께 Delphi, C++Builder, Lazarus/FPC용 PDFium 컴포넌트의 일부로 제공된다