[Format] OLE #04. HWP - BodyText

OLE #4. HWP

0x00. Intro

지난 Post를 통해 CFBF 파일 포맷의 기본 구조를 확인해 보았다. 이를 통해 Directory 내의 개별 Storage/Stream을 별도의 파일로 저장까지 가능하다. 따라서 개별 Storage/Stream을 분석하면 우리가 원하는 파일 정보를 얻을 수 있게 된다. CFBF 파일 포맷을 갖는 파일 중 실제 Exploit이 삽입되어 있는 HWP 5.x 파일을 대상으로 분석을 진행한다.

한글과 컴퓨터(이하 한컴)의 한글 에디터는 국내에서 가장 많이 사용하는 문서 에디터일 것이다. 특히 관공서에서 적극적으로 활용한다는 특성상 이를 이용한 악성코드가 많이 발견된다. 대부분은 한글 에디터에서 한글 문서 파일을 읽어들이는 과정에서 발생하는 취약점을 이용하며 이를 통해 문서 파일 내부의 악성코드가 실행되어 2차, 3차 피해를 유발한다. 국내에서는 이러한 문제로 인해 한글 에디터를 대상으로 하는 취약점 분석이 활발히 이루어지고 있으며 한국 인터넷 진흥원(KISA)에서 진행하는 취약점 포상 제도에서도 신규 한글 에디터의 취약점이 대다수를 차지한다. 그로 인해 AV업체에서는 파일 포맷을 분석해 취약점을 유발할 수 있는 코드가 있거나 악성코드가 삽입되어 있는 경우 사전에 차단하는 등의 프로세스를 적용하고 있다.

  • 개발 언어 : Python 2.7.x
  • 개발 환경 : ipython Notebook
  • HWP Specification : 다운로드
  • 사용 도구 : OffVis, SSView
  • 개발 목표
    • HWP 파일 포멧을 일정한 형태로 가져와 화면에 출력한다.
    • 개별 모듈로써 동작하고 이후 별도의 모듈로써 동작할 수 있도록 한다.
    • 확장 및 검색이 가능하도록 한다.
  • 분석 대상 : [SHA-256] AC772E949CBD46FA276A2A7ED28B431DDCC3CCADF7793C6D93264F97C946BB1A
  • 진단 결과 : Malwares.com

0x10. 전체 구조

한글 파일 포맷은 기본적으로 RootEntry를 참조해 데이터를 얻고 포맷의 특성에 따라 레코드 구조 여부나 압축 여부가 결정된다. 압축은 zlib로 되어 있으며 레코드 구조는 다음과 같다.

레코드는 레코드 헤더와 데이터로 구성되어 있으며 레코드 헤더는 크기에 따라 2가지 타입을 갖는다.

  • 타입 1
    • TagID (10Bits)
    • Level (10Bits)
    • Size (12Bits) : Size가 4095Bytes 미만인 경우
    • Data

  • 타입 2
    • TagID (10Bits)
    • Level (10BIts)
    • Size (12Bits) : Size가 4095Bytes 이상인 경우 (12Bits가 모두 1인 경우) -> 레코드 헤더 다음 4Bytes
    • Data

레코드 TagID는 다음과 같은 범위의 값을 갖는다.

  • 0x000 ~ 0x00F : 특별한 용도의 TagID
  • 0x010 ~ 0x1FF : 한글에서 사용하는 TagID (HWPTAG_BEGIN = 0x10)
  • 0x200 ~ 0x3FF : 외부 어플리케이션에서 사용하는 TagID

따라서 TagID 값에 따라 Data를 어떤 구조체에 대입해 분석해야 할 것인지 결정된다.

0x20. 파일 인식 정보 (FileHeader)

FileHeader는 파일 시그니처와 버전 그리고 파일 전체(RootEntry)에 적용되는 속성정보를 담고 있다. FileHeader의 속성 정보중 압축여부와 암호 설정 여부는 파일 포맷을 분석함에 있어서 매우 중요한 정보이다. 즉, 압축된 경우 zlib로 압축을 해제해야 하고 암호가 설정된 경우 한컴에서 제공하는 별도의 암호화가 적용되어 있기 때문에 BodyText 내의 Section 정보를 분석할 수 없다.

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

file_header_path = r"FileHeader.dmp"
data = file(file_header_path, "rb").read()

HEADER_MEMBER_NAME = ("Signature Version Flag Reserved")
HEADER_MEMBER_SIZE = "=32s2l216s"
file_header = namedtuple("FileHeader", HEADER_MEMBER_NAME)._make(unpack(HEADER_MEMBER_SIZE, data))
print file_header.Signature
print file_header.Flag
 HWP Document File               
1

속성 정보(Flag)의 경우 비트단위 연산을 하도록 되어 있다. 하지만 위의 코드와 같이 처리할 경우 Little-Endian 형식의 4Bytes 데이터이므로 특정 속성값을 얻고자 할 때 비트 연산을 별도로 진행해야 한다. 필요한 시점에 비트 연산을 통해 값을 얻을 수도 있지만 다음과 같은 방법도 있으니 참고하기 바란다.

In [30]:
def getbitvalue(val, offset, size):
    val = val >> offset
    tmp = 1

    size -= 1
    while size > 0:
        tmp = tmp | (1 << size)
        size -= 1

    return val & tmp

def bitMap(val, names, offsets, sizes):
    Flag = namedtuple("Flag", names)
    return Flag(*[getbitvalue(val, offsets[i], sizes[i]) for i in xrange(len(names))])

FLAG_MEMBER_NAME   = ["IsPack", "IsEncrypt", "IsDistribution", "IsSavingScript", "IsDRM", "IsSavingXML", "IsDocHistoryOp", "IsExistDigSign", "IsEncryptCert", "IsBackDigSign", "IsDRMCert", "IsCCL", "Reserved"]
FLAG_MEMBER_OFFSET = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
FLAG_MEMBER_SIZE   = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1,  1,  1, 30]

flags = bitMap(file_header.Flag, FLAG_MEMBER_NAME, FLAG_MEMBER_OFFSET, FLAG_MEMBER_SIZE)
file_header = file_header._replace(**{"Flag" : flags})

print file_header.Signature
print file_header.Flag
print file_header.Flag.IsPack
HWP Document File               
Flag(IsPack=1, IsEncrypt=0, IsDistribution=0, IsSavingScript=0, IsDRM=0, IsSavingXML=0, IsDocHistoryOp=0, IsExistDigSign=0, IsEncryptCert=0, IsBackDigSign=0, IsDRMCert=0, IsCCL=0, Reserved=0)
1

0x30. 문서 정보 (DocInfo)

문서 정보는 본문(BodyText, Section)에서 사용중인 글꼴, 글자 속성, 문단 속성 등 본문 내용을 이루는 세부 정보를 담고 있다. ( 파일 포맷 문서상에 오류가 있으므로 "0x80. 스펙 문서상의 수정사항 1" 을 참고하기 바란다.) 레코드 구조로 작성되어 있으므로 그에 맞춰 분석하면 된다.

0x40. 본문 (BodyText/Section)

한글 문서의 본문은 문단, 표, 그리기등의 내용이 저장되며 BodyText Storage 하위에 Section%d Stream으로 구분된다. 이 또한 레코드 구조로 되어 있다. 세부 구조를 분석하기 앞서 Section을 분석한 정보를 출력하면 다음과 같다.

In [49]:
from zlib import decompress

def Decompress(in_buffer):
    return decompress(in_buffer, -15)

def DecompressFile(file_name):
    in_buffer = file(file_name, "rb").read()
    out_buffer = Decompress(in_buffer)
    
    name = file_name.split(".")[0]
    ext = file_name.split(".")[1]
    out_name = "%s_dec%s" % (name, ext)
    file(out_name, "wb").write(out_buffer)
    return out_name

def GetTag(val):
    RECORD_MEMBER_NAME = ["TagID", "Level", "Size"]
    RECORD_MEMBER_OFFSET = [0, 10, 20]
    RECORD_MEMBER_SIZE = [10, 10, 12]
    return bitMap(val, RECORD_MEMBER_NAME, RECORD_MEMBER_OFFSET, RECORD_MEMBER_SIZE)
    
def GetTagObject(data, offset):
    val = unpack("<L", data[offset:offset+4])[0]
    offset += 4
    tag = GetTag(val)
    
    if tag.Size >= 4095:
        new_size = unpack("<L", data[offset:offset+4])[0]
        offset += 4
        tag = tag._replace(**{"Size":new_size})
        
    tmp = data[offset:offset+tag.Size]
    offset += tag.Size
    
    data = namedtuple("TagData", "data")(tmp)
    
    return namedtuple("Record", tag._fields + data._fields)(*(tag + data)), offset
    
        
def GetRecord(file_name):
    data = file(file_name, "rb").read()
    
    offset = 0
    while offset < len(data):
        print "[0x%08X]" % offset,
        taginfo, offset = GetTagObject(data, offset)
        print taginfo.TagID, taginfo.Level, hex(taginfo.Size)
    return "OK"
    
section0 = r"Section0.dmp"
section0_dec = DecompressFile(section0)
print GetRecord(section0_dec)
[0x00000000] 66 0 0x16
[0x0000001A] 67 1 0x2c
[0x0000004A] 68 1 0x8
[0x00000056] 69 1 0x24
[0x0000007E] 71 1 0x26
[0x000000A8] 73 2 0x28
[0x000000D4] 74 2 0x1c
[0x000000F4] 74 2 0x1c
[0x00000114] 75 2 0xe
[0x00000126] 75 2 0xe
[0x00000138] 75 2 0xe
[0x0000014A] 71 1 0x10
OK

0x70. 분석 정보

하지만 비정상 Section을 분석하면 다음과 같은 결과가 나온다.

In [51]:
section1 = r"Section1.dmp"
section1_dec = DecompressFile(section1)
print GetRecord(section1_dec)
[0x00000000] 66 0 0x16
[0x0000001A] 67 1 0x1000022
[0x01000044] 68 1 0x8
[0x01000050] 71 1 0x26
[0x0100007A] 73 2 0x28
[0x010000A6] 74 2 0x1c
[0x010000C6] 74 2 0x1c
[0x010000E6] 75 2 0xe
[0x010000F8] 75 2 0xe
[0x0100010A] 75 2 0xe
[0x0100011C] 71 1 0x10
OK

즉, TagID 67(0x43)의 데이터가 비정상적으로 큰 값임을 알 수 있다. 해당 부분의 바이너리는 \x90과 \xCC 등이 확인된다.

이를 실행파일로 변환하여 (Shellcode2exe) IDA를 통해 디스어셈블리하면 다음과 같은 악성동작으로 추정되는 코드를 확인할 수 있다.

이러한 이유로 A/V 업체에서는 HWPTAG_PARA_TEXT 부분에 악성코드가 있는 것으로 판단하고 진단명을 위와 같이 명명했다.

0x80. 스펙 문서상의 수정사항

한컴에서 공식 제공하고 있는 한글 파일 포맷 문서 버전 1.2에는 잘못된 부분이 많이 존재한다. 따라서 공식 문서만을 참고로 작성하면 실제 분석툴이 정상동작하지 않을 수 있으므로 정상 샘플을 대상으로 문서와 실제 파일상의 데이터를 함께 비교하며 개발해야 한다.

1. DocInfo상의 Size 문제 두번째 표에 있는 26Bytes가 올바른 값이다.

두 값 모두 잘못 표기되어 있다. 실제 파일에서는 30Bytes/72Bytes 도 아닌 64Bytes가 나왔다. 즉, 레코드의 Size에 따라 크기가 다른 것으로 판단된다. 이외에도 구조체 크기에 대한 문제가 발생할 수 있으므로 실제 파일을 통해 검증하기 바란다.

0x90. 버그

0x99. 참고

댓글

가장 많이 본 글