[Format] OLE #03

OLE #03

0x00. Intro

Update. 2015.12.24 버그 수정 및 v0.2 게시

두번의 OLE 파일과 관련된 Post를 통해 "OLE"의 의미분석시 사용될 수 있는 툴을 살펴보았다. 이제 Microsoft와 OpenOffice 문서를 바탕으로 CFBF 파일 포맷 및 OLE 파일 포맷의 내부를 살펴 볼 것이다. ( "OLE" 파일이란 명칭보단 "CFBF" 파일이란 명칭이 더 정확하다고 판단해 앞으로 CFBF 와 OLE을 구분해 사용할 것이다. )

우선 CFBF 파일 포맷을 갖는 파일의 종류를 살펴보면 다음과 같다.

  • Office 파일 (doc, ppt, xls)
  • HWP 파일 (hwp 5.x)
  • MSI 파일

우리가 흔히 알고 있는 문서파일들이 여기에 포함되고 추가로 Windows의 설치 파일인 MSI 파일이 CFBF 파일 포맷으로 분류된다. CFBF 파일 포맷이 여러 파일들을 디렉토리, 파일 구조로 저장할 수 있기 때문에 이를 이용해 설치시 필요한 다양한 파일을 하나의 파일로 묶을 수 있기 때문에 Microsoft에서 MSI 파일 또한 CFBF 파일 형식을 이용한다.

이러한 파일이외에도 DOCX, PPTX, XLSX 등도 세부적으로 살펴보면 CFBF 파일 형식을 사용하지만 다양한 기기 (모바일, xNix 등)에서 사용하기 어렵기 때문에 이를 보완하기 위해 CFBF 파일과 XML 파일을 결합한 형태를 갖도록 했으며 CFBF 파일 형식과 같이 디렉토리, 파일 구조를 갖기 위해 압축한 형태 (zlib 이용) 로 사용된다. 따라서 이후 OOXML 파일 포맷 (DOCX, PPTX, XLSX와 같은 파일 형식) 에 대해 분석할 때 CFBF 파일 포맷 모듈을 사용할 것이다.

앞으로 Specification 문서 중 Microsoft의 공식 문서를 기준으로 작성한다. ( OpenOffice 문서는 Microsoft가 문서를 공개하기 이전에 분석을 통해 그 구조를 파악한 형태이므로 필요한 데이터 위주로 구성되어 있으며 그 용어 또한 Microsoft 문서와 상이해 혼동될 수 있다. )

참고 : 하위의 Specification 중 Microsoft의 문서는 압축 파일로 다수의 파일로 구성되어 있어서 "목차"와 같은 역할의 문서별 주제를 정리한 별도의 문서가 존재한다. 따라서 처음 문서를 살펴볼 때 MS-DOCO.pdf 파일의 Appendix A 를 살펴보거나 원하는 키워드로 검색해 관련 문서를 찾기 바란다.

  • 개발 언어 : Python 2.7.x
  • 개발 환경 : ipython Notebook
  • 사용 도구 : HxD, Notepad++
  • Specification : Microsoft, OpenOffice
  • 개발 목표
    • OLE 파일의 Directory 구조를 Tree 형식으로 출력한다.
    • OLE 파일의 특정 Stream을 추출한다.
  • 개발 모듈 다운로드 : v0.2

0x10. 전체 구조

CFBF 파일 포맷은 FAT 파일 시스템과 매우 닮아 있다. 두 포맷 모두 "Sector"를 기본 단위로 하며 1개 Sector의 크기를 512Bytes (0x200) 로 정의되어 있다. 또한 디렉토리/파일 간의 구조를 이루어져 있다. 이렇게 두 포맷이 닮은 이윤 두 포맷 모두 Microsoft에서 만들어졌으며 FAT 파일 시스템이 개발된 이후 CFBF 파일 포맷이 개발되었기 때문일 것이다. 차이라면 FAT 파일 시스템이 HDD를 대상으로 한다면 CFBF 파일 포맷은 문서, MSI 파일을 기반으로 한다는 점 정도이다.

( 이미지 출처 : http://kaiser30.cafe24.com )

In [2]:
file_path = r"CVE-2015-0097.doc_1"

0x20. Header (파일 헤더)

Header는 CFBF 파일 포맷의 Offset 0 부터 1개 Sector로 구성되어 있다. Header 구조는 다음과 같다.

struct StructedStorageHeader {
    BYTE          _abSig[8];           //\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1
    CLSID         _clsid;
    USHORT        _uMinorVersion;
    USHORT        _uDllVersion;
    USHORT        _uByteOrder;
    USHORT        _uSectorShift;
    USHORT        _uMiniSectorShift;
    USHORT        _usReserved;
    ULONG         _ulReserved1;
    FSINDEX       _csectDir;
    FSINDEX       _csectFat;
    SECT          _sectDirStart;
    DFSIGNATURE   _signaure;
    ULONG         _ulMiniSectorCutoff;
    SECT          _sect<imiFatStart;
    FSINDEX       _csectMiniFat;
    SECT          _sectDofStart;
    FSINDEX       _csectDif;
    SECT          _sectFat[109];
};

Variable Offset Size Description
Signature 00 [0x00] 008 [0x008] Header Signature (\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1)
CLSID 08 [0x08] 016 [0x010] Header CLSID (must NULL)
Minor_Version 24 [0x18] 002 [0x002] Minor Version (0x003E)
Major_Version 26 [0x1A] 002 [0x002] Major Version (0x0003 or 0x0004)
ByteOrder 28 [0x1C] 002 [0x002] ByteOrder (0xFFFE:Little-Endian, 0xFEFF:Big-Endian)
SectShift 30 [0x1E] 002 [0x002] Sector Shift (Major Version 0x0003:0x0009 (512Bytes), 0x0004:0x000C (4096Bytes))
MiniSectShift 32 [0x20] 002 [0x002] Mini Sector Shift (must 0x0006, (64Bytes))
Reserved 34 [0x22] 006 [0x006] Reserved (must Zero)
NumDIR 40 [0x28] 004 [0x004] Number(count) of Directory Sectors (Major Version 0x0003 -> 0x00000000)
NumFAT 44 [0x2C] 004 [0x004] Number(count) of FAT Sectors
DIRSecID 48 [0x30] 004 [0x004] First Directory Sector Location
TansSigNum 52 [0x34] 004 [0x004] Transaction Signature Number
MiniCutoffSize 56 [0x38] 004 [0x004] Mini Stream Cutoff Size (must 0x00001000)
MiniFATSecID 60 [0x3C] 004 [0x004] First Mini FAT Sector Location
NumMiniFATSect 64 [0x40] 004 [0x004] Number(count) of Mini FAT Sectors
DIFATSecID 68 [0x44] 004 [0x004] First DIFAT Sector Location
NumDIFAT 72 [0x48] 004 [0x004] Number of DIFAT Sectors
DIFAT 76 [0x4C] 436 [0x1B4] DIFAT(array of 4Bytes, 109 EA)

Signature는 첫 4Bytes (\xD0\xCF\x11\xE0) 는 CFBF 파일 포맷을 갖는 파일은 모두 동일하지만 다음 4Bytes는 CFBF 파일 포맷을 사용하는 파일에 따라 차이가 발생한다. 따라서 Signature를 첫 8Bytes를 기준으로 할 경우 하위 루틴이 실행되지 않을 수 있음을 기억하기 바란다. (하지만 Microsoft에서 제공한 CFBF 파일 포맷에서는 이러한 사항이 기재되지 않았다.)

MajorVersion은 하위 멤버들의 무결성 검증시 참조되는 값이다. 예를 들어 MajorVersion이 0x0003인 경우 SectShift는 0x0009만 허용된다.

SectShift의 기본값은 0x0009로 1 << SectShift로 실제 값을 알 수 있다. (512Bytes) 일반 문서 파일 포맷은 모두 0x0009로 설정되어 있고 MSI 설치 파일의 경우 0x000C로 설정되어 있다. 따라서 고정값을 사용하기 보단 SectShift를 가져와 연산하는 것이 바람직 하다.

In [3]:
from struct import unpack
from collections import namedtuple

data = file(file_path, "rb").read()

# 직접 구조체 매핑하기
# Out : Dictionary Object
def readBinary(buffer, offset, size):
    return buffer[offset:offset + size]

sig_offset = 0
sig_size = 8
sig = readBinary(data, sig_offset, sig_size)
print "Mapping Directed   : " + " ".join(["0x%02X" % ord(x) for x in sig])


# UNpack을 이용해 구조체 매핑하기
# Out : tuple Object
SectShift_Offset = 30
SectShift = unpack("<H", data[SectShift_Offset:SectShift_Offset + 2])[0]
Sector_Size = 1 << SectShift

HEADER_MEMBER_NAME    = ('Signature CLSID Minor_Version Major_Version ByteOrder SectShift MiniSectShift Reserved NumDIR NumFAT DIRSecID TansSigNum MiniCutoffSize MiniFATSecID NumMiniFATSect DIFATSecID NumDIFAT DIFAT')
HEADER_MEMBER_PATTERN = '=8s16s5h6s9l436s'

Header = namedtuple("Header", HEADER_MEMBER_NAME)._make(unpack(HEADER_MEMBER_PATTERN, data[0:Sector_Size]))
print "Mapping NamedTuple : " + " ".join(["0x%02X" % ord(x) for x in Header.Signature])
Mapping Directed   : 0xD0 0xCF 0x11 0xE0 0xA1 0xB1 0x1A 0xE1
Mapping NamedTuple : 0xD0 0xCF 0x11 0xE0 0xA1 0xB1 0x1A 0xE1

0x30. FAT

FAT는 "File Allocation Table"로 파일의 위치를 저장하고 있는 4Bytes 단위 테이블이다. FAT 또한 CFBF 파일 중 일부 Sector에 저장되어 있으며 해당 위치는 DIFAT에 저장되었다. FAT로 사용될 Sector의 위치가 순서대로 저장되어 있다. 단, 저장된 위치 정보는 인덱스 형태로 다른 테이블을 참조해야 정확한 위치를 알 수 있게 되어있다. 이때 사용되는 테이블 중 하나가 FAT가 된다. 또한 "DIFAT -> FAT -> Sector" 구조로 되어 있는 방식을 "Double-Indirect"라고 하며 이를 줄여 "DI"라고 한다. 따라서 DIFAT는 Double-Indirect FAT를 의미하게 된다.

이렇게 얻어온 FAT Sector 목록은 다음의 수식에 맞춰 연산하면 된다.

(Sector 번호 Sector 크기) + Header 크기

= (Sector 번호 * Sector 크기) + Sector 크기

= (Sector 번호 + 1) * Sector 크기

과정을 정리하면 다음과 같다.

FAT는 다음의 조건이 만족될 때 참고한다.

  • 데이터 명이 "Root Entry" 인 경우
  • 데이터의 크기가 4096Bytes(0x1000) 보다 큰 경우
In [4]:
def read32(buffer, offset):
    return unpack("<L", buffer[offset:offset+4])[0]

# Sector 목록 가져오기
numFAT = Header.NumFAT
if numFAT > 109:
    count = 109
else:
    count = numFAT
    
fat_sect_list = []
for i in xrange(count):
    fat_sect_list.append(read32(Header.DIFAT, i * 4))

print "FAT Sector List : " + str(fat_sect_list)

# FAT 가져오기 
FAT = ""
for i in fat_sect_list:
    FAT += readBinary(data, (i + 1) * Sector_Size, Sector_Size)

print "FAT Length : " + hex(len(FAT))
#print " ".join(["0x%02X" % ord(x) for x in FAT])
FAT Sector List : [17]
FAT Length : 0x200

0x40. Directory

파일을 얻기 위해 참고할 FAT를 구했다면 이제 파일의 위치를 얻어야 한다. 파일의 위치는 CFBF 파일 포맷 중 Directory Sector에 저장되어 있다. Directory Sector 또한 Sector이므로 이전에 구한 FAT를 참고하게 된다. DIFAT와 다르게 FAT는 시작 Sector 번호가 매우 중요하다. 즉, DIFAT는 FAT에 대한 정보를 순차적으로 저장하고 있지만 FAT는 파일 정보를 Linked List 형태로 갖고 있다.

In [10]:
# FAT에서 Directory 관련 Sector 목록 가져오기
dir_sect_list = [Header.DIRSecID]

index = Header.DIRSecID
while True:
    index = read32(FAT, index * 4)
    if index == 0xFFFFFFFE:
        break
    dir_sect_list.append(index)

print "Directory Sector List : " + str(dir_sect_list)
print "Directory Sector Offset : " + str([ hex((i + 1) * Sector_Size) for i in dir_sect_list])

# Directory 구하기
Directory = ""
for i in dir_sect_list:
    Directory += readBinary(data, (i + 1) * Sector_Size, Sector_Size)

print "Director Length : " + hex(len(Directory))
Directory Sector List : [18, 19, 22, 28]
Directory Sector Offset : ['0x2600', '0x2800', '0x2e00', '0x3a00']
Director Length : 0x800

Directory 구조는 다음과 같다.

Variable Offset Size Description
Name 000 [0x00] 64 [0x040] Storage or Stream Name (Unicode String)
Length 064 [0x40] 02 [0x002] Name Length (Unicode String Length), Maximum 64Bytes
Type 066 [0x42] 01 [0x001] Object Type(must 0x00, 0x01, 0x02, 0x05)
ColorFlag 067 [0x43] 01 [0x001] (must 0x00, 0x01)
LeftID 068 [0x44] 04 [0x004] Stream ID of the Left sibling
RightID 072 [0x48] 04 [0x004] Stream ID of the Right sibling
ChildID 076 [0x4C] 04 [0x004] Stream ID of a child object
CLSID 080 [0x50] 16 [0x010] Object Class GUID (stream must be set to all zeros)
State 096 [0x60] 04 [0x004] User-Defined Flag
CreateTime 100 [0x64] 08 [0x008] creation time
ModifyTime 108 [0x6C] 08 [0x008] modification time
SecID 116 [0x74] 04 [0x004] First Sector Location
Size 120 [0x78] 08 [0x008] LowSize(4Bytes) + HighSize(4Bytes)

Directory는 128Bytes (0x80) 크기의 구조체들로 구성되어 있다. 따라서 1개 Sector 내에 4개의 파일 정보를 담을 수 있게 된다.

In [16]:
def read16(buffer, offset):
    return unpack("<H", buffer[offset:offset+2])[0]

def readUnicode(buffer, offset, max_size=0):
    out = ""
    while True:
        if buffer[offset:offset+2] == "\x00\x00":
            break
        out += buffer[offset:offset+2]
        if max_size != 0 and len(out) >= max_size:
            break
        offset += 2
    return out

DIRECTORY_MEMBER_NAME = ("Name Length Type ColorFlag LeftID RightID ChildID CLSID State CreateTime ModifyTime SecID Size")
DIRECTORY_MEMBER_PATTERN = "=64s1h2B3L16s1l2Q1l1Q"

start_offset = 0
Dir_Sector_Size = 128
Length_Offset = 64
Length = read16(data, Length_Offset)
Name = readUnicode(Directory, start_offset)[::2].replace(" ", "")
RootEntry = namedtuple(Name, DIRECTORY_MEMBER_NAME)._make(unpack(DIRECTORY_MEMBER_PATTERN, Directory[start_offset:Dir_Sector_Size]))
print RootEntry
RootEntry(Name='R\x00o\x00o\x00t\x00 \x00E\x00n\x00t\x00r\x00y\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', Length=22, Type=5, ColorFlag=1, LeftID=4294967295L, RightID=4294967295L, ChildID=10, CLSID='\x06\t\x02\x00\x00\x00\x00\x00\xc0\x00\x00\x00\x00\x00\x00F', State=0, CreateTime=0, ModifyTime=130570204878650000L, SecID=21, Size=3136)

따라서 Directory 크기에 맞춰 분석하면 CFBF 파일 포맷 내에 저장되어 있는 Storage/Stream 정보를 모두 얻을 수 있다.

  • Storage : 폴더 또는 Directory와 같은 개념으로 크기는 0이지만 하위 Stream의 종류/목적을 구분할 때 사용된다.
  • Stream : 파일과 같은 개념으로 실제 데이터가 존재한다.

0x50. MiniFAT

CFBF 파일 포맷의 Stream이 Sector 단위 (512Bytes) 보다 작을 경우 (예 : 10Bytes) Sector 1개를 모두 사용하면 쓸모없는 공간이 발생한다. 따라서 공간을 효율적으로 사용할 수 있도록 더 작은 단위를 설계했다. 이를 MiniSector라고 한다. Sector가 FAT를 참조해 위치를 연산했듯 MiniSector 또한 MiniFAT을 참조해 위치를 연산한다. 하지만 MiniFAT 또한 Sector 단위로 저장되어 있으므로 FAT를 참조해 MiniFAT를 구하게 된다. MiniFAT을 구하는 과정은 Directory를 구하는 과정과 동일하다.

In [17]:
# FAT에서 MiniFAT 관련 Sector 목록 가져오기
mini_sect_list = [Header.MiniFATSecID]

index = Header.MiniFATSecID
while True:
    index = read32(FAT, index * 4)
    if index == 0xFFFFFFFE:
        break
    mini_sect_list.append(index)

print "MiniFAT Sector List : " + str(mini_sect_list)
print "MiniFAT Sector Offset : " + str([ hex((i + 1) * Sector_Size) for i in mini_sect_list])

# MiniFAT 구하기
MiniFAT = ""
for i in mini_sect_list:
    MiniFAT += readBinary(data, (i + 1) * Sector_Size, Sector_Size)

print "MiniFAT Length : " + hex(len(MiniFAT))
MiniFAT Sector List : [20]
MiniFAT Sector Offset : ['0x2a00']
MiniFAT Length : 0x200

MiniFAT을 참조할 조건은 다음과 같다.

  • 데이터의 크기가 4096Bytes (0x1000) 보다 작은 경우

주의할 점은 MiniFAT을 참조하더라도 특정 Sector 내의 MiniSector 목록을 알려주기 때문에 목록내 인덱스를 Sector Offset 정보와 MiniSector Offset 정보로 나누어 접근해야 한다. Sector 위치와 MiniSector 위치를 따로 얻는 방식에 따라 구현 방식이 다음과 같이 나눌 수 있다.

[ 구현 방식 1 ]
sect_off = index / (Sector_Size / Mini_Sector_Size) * Sector_Size
mini_sect_off = index / (Sector_Size / Mini_Sector_Size) * Mini_Sector_Size

[ 구현 방식 2 ]
sect_off = index * Mini_Sector_SIze    

0x90. Bug

Bug 1. Directory 구조체 내의 이름을 얻을 때 특수 문자에 의해 인코딩 오류 발생

Directory 구조체 내의 이름이 "Root Entry", "SummaryInformation"과 같이 알수 있는 영문자로 되어 있는 경우는 발생하지 않지만 "\x01CompObj"와 같이 첫 1Byte (Unicode 2Bytes)가 Hex로 되어 있는 Binary일 경우 namedtuple이나 그외 Name을 이용한 작업시 오류가 발생할 수 있다. 따라서 다음과 같이 해당 부분을 수정하면 도움이 된다.

if not (0x21 <= ord(DirName[0]) <= 0x7E):
        DirName = "%s%s" % (ord(DirName[0]), DirName[1:])

이 경우도 Directory 구조체 이름의 첫 글자가 숫자인 경우 namedtuple()을 통한 구조화 과정에서 오류가 발생한다. 따라서 숫자가 있는 경우 별도의 처리가 필요하다. v0.2 에서 Directory 구조체 이름의 숫자를 이름의 끝으로 옮기는 방식으로 파일명을 지정했지만 실제 Directory 구조체 이름은 변경하지 않았다.

def _chkName(self, DirName, Directory=[]):
    # \x01CompObj -> CompObj1
    if not ((0x21 <= ord(DirName[0]) <= 0x2F) or (0x3A <= ord(DirName[0]) <= 0x7E)):
        DirName = "%s%s" % (DirName[1:], ord(DirName[0]))

    # Directory = [CompObj1]
    # DirName: CompObj1 -> CompObj2
    if DirName in Directory:
        if 0 <= int(DirName[-1]) <= 9:
            DirName = "%s%s" % (DirName[:-1], int(DirName[-1]) + 1)
        else:
            DirName = "%s0" % DirName

    return DirName

Bug 2. Directory 구조체 내의 이름이 중복되는 경우

Directory 구조체 내의 이름이 중복되어 존재할 수 있다. 따라서 중복 가능성을 두고 분석 후 저장해야 한다.

Bug 3. Dump 함수 수정

분석할 Storage/Stream을 별도의 파일로 저장하는 과정에서 오류가 발생했다. 오류의 원인은 한글의 경우 하위 구조체는 RootEntry 내에 모두 존재하기 때문에 MiniSector의 경우 파일에서 추출하는 것이 아닌 RootEntry을 참조해야 한다. 따라서 다음과 같이 수정했다.

def _getMiniSectors(self, start_sector_id):
    sect_list = self._getList(self.MiniFAT, start_sector_id)
    sectors = ""
    for i in sect_list:
        if self.Kind == "Hwp" and len(self.RootEntry) != 0:
            sectors += self.readBinary(self.RootEntry, i * self.Mini_Sector_Size, self.Mini_Sector_Size)
        else:
            sectors += self.readBinary(self.data, i * self.Mini_Sector_Size, self.Mini_Sector_Size)

    return sectors

댓글

가장 많이 본 글