[Format] OLE #05. Excel - Formula

[Format] OLE #05. Excel - Formula

0x00. Intro

이전 Post를 통해 CFBF 파일 포맷의 기본 구조를 확인해 보았다. 그 후 CFBF 파일 포맷을 갖는 악성 문서 파일을 분석해 악성 여부를 확인해 보았다.

이번에는 최근 다시 이슈가 되고 있는 매크로 바이러스에 대해 알아보려고 한다.

(발췌 : 악성코드 기술도 복고? 매크로 바이러스의 귀환)

"매크로"는 "파일 연산등 단순 반복작업을 여러번 반복할 수 있도록 저장해 놓은 자동화" 정도로 생각하면 된다. 즉, Excel에서 셀 값의 합을 구하는 등의 작업을 떠올리면 된다. 이러한 "매크로" 또한 코딩이라 할 수 있기 때문에 이를 이용한 악성코드가 존재하고 이를 "매크로 바이러스"라 한다.

( 발췌 : [aSSIST-(주)안랩 조시행 전무 특강 자료] 악성코드와 백신 그리고 보안위협.pdf )

최초로 발견된 매크로 바이러스는 1996년 ~ 1997년경에 발견된 XM/Laroux(malwares.com 결과) 바이러스로 malwares.com 결과를 보면 알 수 있듯 현재까지 유사 변종 악성코드가 꾸준히 배포되고 있다. 이러한 매크로 바이러스에 대해 마이크로 소프트는 다음과 같은 차단을 위한 노력을 기울였다.

  • 마이크로 소프트의 매크로 바이러스 차단을 위한 노력들
    • 2003년 매크로에 디지털 서명을 삽입해 디지털 서명이 되지 않은 매크로 실행 차단
    • 2013년 파일 열람시 사용자에게 매크로 실행 알림창 활성화

보안이 그렇듯 방어 기술이 나오면 이를 우회하는 공격기술이 나온다. 하지만 매크로 바이러스는 굳이 그럴 필요가 없었다. XM/Laroux와 같은 매크로 바이러스는 Excel 파일을 이용한 형태가 가장 많으며 매크로는 Excel의 기본 기능이기 때문에 사용자 또한 매크로 실행 알림창이 활성화되더라도 무시하게 된다. 따라서 마이크로 소프트이 정책을 통한 차단도 도움이 되겠지만 파일을 분석해 사전에 악성 매크로의 존재여부를 판단/차단하는 것이 더욱 강력할 것이라 생각된다.

수많은 매크로 바이러스 중 이번 Post에서 대상으로 하는 매크로 바이러스는 Excel Formula 연산을 이용하는 매크로 바이러스인 XF.Sic을 대상으로 한다.

  • 개발 언어 : Python 2.7.x
  • 개발 환경 : ipython notebook
  • Specification : Microsoft 중 [MS-XLS].pdf
  • 사용 도구 : OffVis, SSView
  • 개발 목표
    • Excel 내부 구조(Record)를 출력할 수 있다.
    • Excel 내에 삽입되어 있는 Formula 매크로롤 추출할 수 있다.
  • 분석 대상 : [SHA-256] 011D7520A5D6128F4C7B0010276EF3101238DEF344CB603D4E28D192A5F55454
  • 진단 결과 : malwares.com

0x10. Excel 파일 구조

CFBF 파일 포맷의 Storage/Stream 중 Excel과 관련된 데이터가 저장된 Storage/Stream은 Workbook Stream이다. 따라서 이전 Post에서 설명한 것처럼 CFBF 파일 포맷에서 Workbook Stream을 추출해 해당 Stream에 해당하는 구조를 분석하면 된다.

OffVis로 샘플 파일을 확인해 보면 위와 같이 ExcepBinaryDocument, Globals, Worksheets, SubStream을 갖고 있음을 알 수 있다. 각각의 의미를 Excel 파일로 확인해 보면 다음과 같다.

  • ExcelBinaryDocument : Excel 파일
  • Globals : Excel 파일 전체에 영향을 미치는 공통 정보
  • Worksheets : Excel 파일 내의 모든 Sheets
  • SubStream : Excel 파일내의 개별 Sheet에 대한 정보로 파일내의 Sheet 개수와 SubStream 개수가 일치

즉, Workbook Stream 내에 Excel 파일에 대한 모든 정보가 있으므로 Workbook Stream을 분석하면 Excel을 구성하는 위와 같은 정보를 확인할 수 있다. 그럼 Workbook Stream의 내부 구조는 어떻게 되어 있을까?

In [7]:
file_path = r"Workbook.stream"

0x20. Record 구조

Workbook은 이전 Hwp 파일 포맷에서 분석한 Record와 유사한 구조를 갖고 있다. 차이는 이전 Hwp 파일 포맷상의 Record는 2가지 타입을 갖고 있다면 Workbook의 Record는 1가지 타입으로 되어 있다. 이를 BIFF Record라 한다.

  • BIFF : Binary Interchange File Format

  • Record

    • Record Type (2Bytes) : 데이터를 저장하고 있는 Record 정보의 타입을 정의한다.
    • Record Size (2Bytes) : 저장하고 있는 데이터의 길이를 나타낸다. ( 0 <= Size <= 8224 )
    • Record Data (가변길이) : 데이터 타입에 따라 다른 구조로 되어 있다.

만약 Record Data가 8224Bytes 보다 클 경우 Hwp 파일 포맷의 Record와 다르게 별도의 Record에 추가 데이터를 8224Bytes 단위로 저장하도록 되어 있다.

  • 추가 데이터를 저장하는 Record
    • Continue, ContinueFrt, ContinueFrt11, ContinueFrt12
In [8]:
def GetRecord(workbook, offset):
    RECORD_NAME = ("type size data")
    RECORD_PATTERN = "=2H%ds" % (read16(workbook, offset+2))
    record_size = MapSize(RECORD_PATTERN)
    record = Map("record", workbook[offset:offset + record_size], RECORD_NAME, RECORD_PATTERN)
    return addMap("record", record, "offset", offset), offset + record_size

0x30. Workbook Stream

Record 중 Workbook의 시작과 끝은 각각 BOF, EOF Record가 위치하게 되어 있다. 따라서 위의 그림을 보면 Book Stream을 4개로 나눌 수 있게 된다. 즉, 위 그림에서 Workbook Compound File을 우리가 알고 있는 Excel 파일이라 하면 Book Stream이 Workbook Stream이 된다. 또한 BOF~EOF를 하나의 단위로 할 때 4개가 존재하고 첫 부분이 "Globals", 두번째부터 "SubStream"으로 명명되어 있다. 따라서 BOF~ EOF 단위를 정확히 나누어 분석하지 않으면 정확한 분석이 되지 않을 수 있기 때문에 모듈 작성시 주의해야 한다.

대상 샘플의 경우 "Globals", "SubStream[0]" ~ "SubStream[4]"로 여섯부분으로 나눌 수 있게 된다. 각각 하위에 BIFFRecords가 존재한다. 이는 BIFF 형태의 Record 들로 구성되어 있음을 의미한다.

그 중 최상위의 "Globals"를 확인해 보면 다음과 같다.

In [9]:
# Workbook에서 BOF~EOF 단위로 BIFFRecord 가져오기 

from struct import unpack, calcsize
from collections import namedtuple

def Map(struct_name, buffer, member_name, member_pattern):
    return namedtuple(struct_name, member_name)._make(unpack(member_pattern, buffer))

def MapSize(member_pattern):
    return calcsize(member_pattern)

def addMap(new_name, namedtuple1, add_member_name, add_member_value):
    return namedtuple(new_name, namedtuple1._fields+(add_member_name, ))(*(namedtuple1 + (add_member_value,)))

def read16(data, offset):
    return unpack("<H", data[offset:offset+2])[0]
    
def parseWorkBook(workbook, offset):
    records = []
    
    EOF_TYPE = 0x000A
    while offset < len(workbook):
        record, offset = GetRecord(workbook, offset)
        records.append(record)
        if record.type == EOF_TYPE:
            break
    
    return records, offset
        
def parse(workbook):
    biff = []
    
    offset = 0
    while offset < len(workbook):
        records, offset = parseWorkBook(workbook, offset)
        biff.append(records)
    
    return biff
    
workbook = file(file_path, "rb").read()
biff = parse(workbook)
print len(biff) 
6

대상 샘플이 "Globals", "SubStream[0]" ~ "SubStream[4]" 여섯 부분으로 나누어져 있던 것처럼 Record 분석 결과 또한 여섯 부분으로 나눌 수 있음을 알 수 있다. Record가 정상적으로 분석이 완료되었는지 확인해 보면 다음과 같다.

In [10]:
BOF_TYPE = 0x0809
EOF_TYPE = 0x000A

for i in xrange(len(biff)):
    print "[ %d 번째 ]" % i
    if biff[i][0].type != BOF_TYPE or biff[i][-1].type != EOF_TYPE:
        print "[ERROR] record type is miss match!"
    else:
        print "BOF ->" + str(biff[i][0])
        print "EOF ->" + str(biff[i][-1])
[ 0 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06\x05\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=0)
EOF ->record(type=10, size=0, data='', offset=135715)
[ 1 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06\x10\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=135719)
EOF ->record(type=10, size=0, data='', offset=178924)
[ 2 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06\x10\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=178928)
EOF ->record(type=10, size=0, data='', offset=185485)
[ 3 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06\x10\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=185489)
EOF ->record(type=10, size=0, data='', offset=191160)
[ 4 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06\x10\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=191164)
EOF ->record(type=10, size=0, data='', offset=196114)
[ 5 번째 ]
BOF ->record(type=2057, size=16, data='\x00\x06@\x00\x9b \xcd\x07\xc9\xc0\x00\x00\x06\x03\x00\x00', offset=196118)
EOF ->record(type=10, size=0, data='', offset=199821)

뭔가 이상한 점을 찾지 못했나? 분명 나는 Workbook을 Record 단위로 분석했다. 그런데 Globals 부터 SUbStream까지 모든 데이터가 순차적으로 출력되었다. 맞는것일까?

실제 Globals를 제외한 SubStream은 바이너리상으론 연속되어 존재한다. 하지만 그 순서가 SubStream[0] 부터 존재한다는 보장은 할 수 없다. 따라서 Globals에서 SubStream에 대한 정보를 저장하고 있는 Record를 찾아 분석해 SubStream[0] ~ SubStream[4]를 찾아야 한다.

0x40. BoundSheet

Globals에서 SubStream에 대한 정보를 담고 있는 Record는 "BoundSheet" Record이다. 따라서 Record Type이 BoundSheet인 것 (0x0085)를 찾고 BoundSheet Record를 분석해 SubStream의 정보를 추출하면 된다. 우선 BoundSheet Record 구조부터 살펴보자.

( OpenOffice 프로젝트를 통해 공개된 Excel Workbook의 BoundSheet Record 정보이다. 마이크로 소프트에서 공개된 것도 존재하지만 보기 쉽게 정리되어 있어 BoundSheet Record는 OpenOffice 문서를 참조했다. )

BoundSheet Record 중 "lbPlyPos"가 SubStream이 위치하는 곳의 Offset이 된다. 이때 Offset은 절대 주소가 아닌 상대 주소라는 점이다. 즉, 파일의 시작부터의 Offset이 아닌 기준이되는 위치로 부터의 Offset이며 그 기준 위치는 WorkBook Stream의 시작 위치가 된다. 따라서 실제 SubStream의 위치는 다음과 같다.

따라서 정확한 연산을 위해선 다음과 같은 순서로 분석을 진행해야 한다.

  1. WorkBook Stream을 추출한다.
  2. WorkBook Stream 중 "Globals"를 분석한다.
  3. Globals에서 BoundSheet Record를 찾아 SubStream의 위치 정보를 획득한다.
  4. WorkkBook Stream 내 SubStream을 추출한다.
  5. SubStream을 Record 단위로 분석한다.

하지만 결과론적으로 SubStream내에 Formula Record를 찾는 것은 모든 Record 중 Formula Record를 찾는 것과 같다. 우리의 목적상 Formula Record가 위치하는 Sheet가 중요하진 않기 때문이다. ( 만약 치료를 목적으로 한다면, Formula Record가 동작하지 못하도록 하거나 Formula를 제거하는 것을 목적으로 한다면 Sheet 정보는 매우 중요하다. ) 따라서 WorkBook Stream 내에서 모든 Record를 추출했고 순차적으로 SubStream[0], SubStream[1]... 등으로 명명한 것 뿐이다.

0x50. Formula Record 구조 분석

모든 Record를 가져왔다. 이 중 우리가 찾고자 하는 것은 매크로를 저장하고 있는 Record이다. 다시 위의 그림을 참고해 보자.

매크로는 파일 전체에 영향을 미치지 않고 Sheet 단위이거나 Sheet 내 Cell 단위이다. 즉, Globals가 아닌 개별 Sheet인 SubStream과 연관이 있다. 따라서 우리가 BOF~EOF 단위로 얻어온 Record 중 SubStream0 ~ SubStream4에 매크로가 존재하게 될 것이다. 그럼 매크로와 관련된 Record Type을 SubStream[0] ~ SubStream[4]에서 찾으면 될 것이다.

In [17]:
FORMULA_TYPE = 0x0006

formula = {}
for i in xrange(len(biff)):
    substream = biff[i]
    
    out = []
    for record in substream:
        if record.type == FORMULA_TYPE:
           out.append(record)
    
    formula[i] = out

print "Globlas : %d" % len(formula[0])
print "SubStream[0] : %d" % len(formula[1])
print "SubStream[1] : %d" % len(formula[2])
print "SubStream[2] : %d" % len(formula[3])
print "SubStream[3] : %d" % len(formula[4])
print "SubStream[4] : %d" % len(formula[5])
Globlas : 0
SubStream[0] : 643
SubStream[1] : 42
SubStream[2] : 20
SubStream[3] : 4
SubStream[4] : 54

검색 결과 "Globals"를 제외한 SubStream에서 모두 Formula Record가 확인되었다. 이제 Formula Record의 구조를 분석하면 된다. 방식은 BoundSheet와 동일한다.

BoundSheet Record의 구조는 위와 같다. Record.data에서 rgce를 세부 분석해 Formula Script를 가져오게 된다.

  • 참고 :: Formula Record의 rgce를 분석할 때 사용되는 명칭이 마이크로 소프트 공식 문서와 OpenOffice 문서가 서로 상이하다. 혹 두개의 문서를 모두 참조할 경우 참고하기 바란다.
    • 마이크로 소프트 : Ptg (= Parse Thing)
    • OpenOffice : t (=Token)

0x60. Ptg 분석

Formula Record의 rgce는 1개 이상의 Ptg 단위로 나눌 수 있다. Ptg는 첫 1Byte에 따라 Ptg를 분류하게 되고 이렇게 분류 가능한 Ptg는 86 종류가 존재한다. 이 중 PtgElf(0x18)과 PtgAttr(0x19)는 두번째 1Byte에 의해 좀 더 세부적으로 분류된다. 하나의 Formula Record는 1개 이상의 Ptg가 존재하므로 순차적으로 분석해야 한다.

각각의 Ptg는 모두 다른 목적으로 작성되었기 때문에 개별 분석이 이루어져야 하며 이러한 과정을 통해 추출된 Ptg 정보를 조합해 매크로 스트립트를 추출하게 된다.

In [58]:
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))])

def mergeNamedtuple(new_name, namedtuple1, namedtuple2):
    return namedtuple(new_name, namedtuple1._fields + namedtuple2._fields)(*(namedtuple1 + namedtuple2))

def read8(data, offset):
    return unpack("<B", data[offset])[0]

def read8Ex(data, offset):
    return read8(data, offset), offset + 1

def read16Ex(data, offset):
    return read16(data, offset), offset + 2

def read32(data, offset):
    return unpack("<L", data[offset:offset+4])[0]

def read32Ex(data, offset):
    return read32(data, offset), offset+4

def readBinary(data, offset, size):
    return data[offset:offset+size]

def readBinaryEx(data, offset, size):
    return readBinary(data, offset, size), offset + size

def getCellEx(data, offset):
    CELL_MEMBER = ("rw col ixfe")
    CELL_PATTERN = "=3H"
    end_offset = offset+MapSize(CELL_PATTERN)
    return Map("Cell", data[offset:end_offset], CELL_MEMBER, CELL_PATTERN), end_offset

def getFormulaValue(data, offset):
    FORMULA_VAL_MEMBER = ("byte1 byte2 byte3 byte4 byte5 byte6 fExpr0")
    FORMULA_VAL_PATTERN = "=6B1H"
    end_offset = offset + MapSize(FORMULA_VAL_PATTERN)
    return Map("FormulaValue", data[offset:end_offset], FORMULA_VAL_MEMBER, FORMULA_VAL_PATTERN), end_offset

def parseFlag(val):
    FLAG_MEMBER = ["fAlwaysCalc", "fReserved1", "fFill", "fShrFmal", "fReserved2", "fClearErrors", "fReserved3"]
    FLAG_OFFSET = [0, 1, 1, 2, 3, 4,  6]
    FLAG_SIZE   = [1, 1, 1, 1, 1, 1, 10]
    return bitMap(val, FLAG_MEMBER, FLAG_OFFSET, FLAG_SIZE)

def getCellParsedFormula(data, offset):
    cce, offset = read16Ex(data, offset)
    rgce, offset = readBinaryEx(data, offset, cce)
    return namedtuple("formula", ["cce", "rgce"])(cce=cce, rgce=rgce), offset

def getObject(ptgType):
    if   ptgType == 0x17: return PtgStr()
    elif ptgType == 0x42: return PtgFuncVar()

class PtgStr:
    TYPE = 0x17
    NAME = "PtgStr"
    def __init__(self):
        pass
    
    def parse(self, ptgType, rgce, offset):
        cch, offset = read8Ex(rgce, offset)
        mod, offset = read8Ex(rgce, offset)
        if mod == 0:
            len = cch
        else:
            len = cch * 2
        string, offset= readBinaryEx(rgce, offset, len)
        return namedtuple("PtgStr", ["ptgType", "cch", "mod", "string"])(ptgType=ptgType, cch=cch, mod=mod, string=string), offset   

class PtgFuncVar:
    TYPE = 0x42
    NAME = "PtgFuncVar"
    def __init__(self):
        pass
    
    def _parseFlag(self, val):
        MEMBER_NAME   = ["tab", "fCeFunc"]
        MEMBER_OFFSET = [ 0, 15]
        MEMBER_SIZE   = [15, 1]
        return bitMap(val, MEMBER_NAME, MEMBER_OFFSET, MEMBER_SIZE)
    
    def parse(self, ptgType, rgce, offset):
        cparams, offset = read8Ex(rgce, offset)
        tmp = namedtuple("tmp", ["ptgType", "cparams"])(ptgType=ptgType, cparams=cparams)
        
        val, offset = read16Ex(rgce, offset)
        flag = self._parseFlag(val)
        
        return mergeNamedtuple("PtgFuncVar", tmp, flag), offset    
    
def parseFormula(formula):
    result = []
    
    offset = 0
    while offset < len(formula.rgce):
        ptgType, offset = read8Ex(formula.rgce, offset)
        obj = getObject(ptgType)
        ptg, offset = obj.parse(ptgType, formula.rgce, offset)
        result.append(ptg)
    
    return result

def parse(data):
    offset = 0
    cell, offset = getCellEx(data, offset)
    formulavalue, offset = getFormulaValue(data, offset)
    tmp1 = mergeNamedtuple("tmp1", cell, formulavalue)
    
    val, offset = read16Ex(data, offset)
    flag = parseFlag(val)
    tmp2 = mergeNamedtuple("tmp2", tmp1, flag)
    
    chn, offset = read32Ex(data, offset)
    tmp3 = addMap("tmp3", tmp2, "chn", chn)
    
    formula, offset = getCellParsedFormula(data, offset)
    formula = parseFormula(formula)
    
    data = addMap("data", tmp3, "formula", formula)
    return record._replace(**{"data":data})   

test_record = formula[5][0]
test_record = parse(test_record.data)

print test_record.data
print test_record.data.formula[0].string
data(rw=0, col=2, ixfe=572, byte1=1, byte2=0, byte3=1, byte4=0, byte5=0, byte6=0, fExpr0=65535, fAlwaysCalc=0, fReserved1=0, fFill=0, fShrFmal=0, fReserved2=0, fClearErrors=0, fReserved3=0, chn=4261543939L, formula=[PtgStr(ptgType=23, cch=8, mod=0, string='00000ppy'), PtgFuncVar(ptgType=66, cparams=1, tab=383, fCeFunc=1)])
00000ppy

0x90. 버그

0x99. 참고

댓글

가장 많이 본 글