[Format] PDF

PDF.py

0x00. Intro

PDF 파일 포멧에 대해 알아보겠다.

PDF 파일은 Adobe사에서 제공하는 문서 파일 포멧으로 컴퓨터 환경에 관계없이 같은 표현을 위한 목적으로 개발된 문서 파일 포맷으로 HWP, DOC등 많은 문서 파일에서 변환을 지원하는 범용 파일 포멧이다.

  • 특징 : 장치 독립성, 해상도 독립성

범용 파일로써 변환을 지원하기 위해 Adobe사에서는 PDF 파일 포멧 문서를 제공하고 있으므로 본 문서도 PDF 파일 포멧 문서를 기초로 한다. (참조 : "PDF Specification") 그리고 PDF 파일 포맷의 특징상 메모장과 같은 프로그램으로도 구조를 확인할 수 있으므로 앞으로 좀더 기능이 많은 "Notepad++" 프로그램을 사용한다.

  • 개발 언어 : Python 2.7.x
  • 개발 환경 : ipython Notebook
  • PDF Specification : 다운로드
  • 사용 도구 : HxD, Notepad++
  • 개발 목표
    • PDF 파일 포멧을 일정한 형태로 가져와 화면에 출력한다.
    • 개별 모듈로써 동작하고 이후 별도의 모듈로써 동작할 수 있도록 한다.
    • 확장 및 검색이 가능하도록 한다.

* 개발 모듈 다운로드 : v0.1

In [4]:
# -*- coding:utf-8 -*-

fname = r"pdf_reference_1-7.pdf"

0x10. 전체 구조

PDF 파일은 기본적으로 "Header(헤더)", "Body(바디)", "Cross-reference table(참조테이블)", "Trailer(트레일러)"로 구성되어 있다.

  • Header (헤더) : PDF 파일 시그니처와 버전 정보로 구성되어 있다.
  • Body (바디) : 오브젝트로 구성되어 있으며 PDF 뷰어로 보여지는 그림, 본문등의 정보를 담고 있다.
  • Cross-reference table (참조테이블) : Body의 오브젝트에 대한 위치정보등을 담고 있다.
  • Trailer (트레일러) : PDF 파일의 끝에 위치하며 PDF 파일의 시작 오브젝트 정보와 파일 크기 정보등을 담고 있다.

0x20. Header (파일 헤더)

헤더의 첫 8Bytes는 PDF 파일의 시그니처가 존재한다. 시그니처의 형태는 "%PDF-1.x" 형태로 되어 있다.

* PDF 시그니처 정규표현식 : "^%PDF-[0-9].[0-9]"

시그니처 정보 중 "%PDF-" 이후의 숫자는 PDF의 버전정보를 의미한다. 이 버전 정보에 따라 지원하는 Adobe Acrobat Reader의 버전을 알 수 있다.

In [5]:
import re

def Ispossible(fname):
    pdf = file(fname, "rb").read(8)
    
    pdf_sig = "^%PDF-[0-9]\.[0-9]"
    if bool(re.match(pdf_sig, pdf)):
        return "PDF"
    else:
        return None

print Ispossible(fname)
PDF

기본형은 파일의 시작위치부터 8Bytes내에 시그니처가 존재해야 하지만 간혹 시그니처 앞에 다른 데이터가 존재하는 경우가 있다. 물론 해당 파일은 알려진 PDF 뷰어로 확인할 수 없다. (실행되지 않는다.) 하지만 이러한 부분도 파일 분석시에는 고려해야 할 대상이 된다. 따라서 단순히 시작 위치에 시그니처가 존재하느냐 보다는 그 위치가 파일의 시작인지 확인하는 것 또한 의미있는 정보라 할 수 있다. 또는 시그니처 시작 위치부터 분석을 진행하는 것도 하나의 방법이 될 수 있다.

* 포인트 : PDF 파일 시그니처는 반드시 파일의 시작부터 8Bytes 이내에 존재해야 하지만 PDF 파일내에 다른 PDF파일이 존재하거나 시그니처 앞에 추가 데이터가 존재할 수 있다.

0x30. Body (바디)

Body는 PDF 파일의 모든 내용이 모여있는 곳이다. 그림, 글, 표등 다양한 내용을 Body 내에 저장한다. Body에 저장할 때 PDF는 오브젝트(Object) 단위로 저장한다. 하나의 Object가 표일 수도 있고 한 페이지를 의미할 수 도 있다. 따라서 PDF 파일의 내용, 그림등을 확인하려면 PDF 파일의 Object를 확인하면 된다. 그럼 PDF 파일에서 Object는 어떤 구조일까?

Object는 기본적으로 다음과 같은 구조를 갖는다.

N 0 obj               <- Object의 시작 지점
<<                    <- Attribute의 시작 지점
   /속성1             <- Attribute의 속성 정보들
   /속성2                /      : 구분자
   /속성3   값           "속성" : 속성명
>>                    <- Attribute의 끝 지점
stream                <- stream 데이터 시작 지점
(stream 데이터)       <- stream 데이터 
endstream             <- stream 데이터 끝 지점
endobj                <- Object의 끝 지점

이러한 구조상 "N 0 obj" 와 "endobj"는 반드시 존재하며 "stream" 과 "endstream"은 stream 데이터가 있을 경우만 존재한다. 따라서 "N 0 obj"와 "endobj"를 기준으로 Object를 가져올 수 있다.

* PDF Object 정규 표현식 : "(\d+)\s\d+\sobj([\s\S]*?)endobj"

In [6]:
def getobjects(fname):
    pdf = file(fname, "rb").read()
    
    regex_obj = "(\d+)\s\d+\sobj([\s\S]*?)endobj"
    return re.findall(regex_obj, pdf)

objects = getobjects(fname)
print objects[0]
('333276', ' <</Linearized 1/L 32472771/O 333280/E 303423/N 1310/T 25807200/H [ 17000 169352]>>\r')

반환한 Objects는 list 타입이며 개별 Object는 Tuple 타입으로 되어 있다. Tuple의 첫번째 값은 "Object 번호" 이고 두번째 값은 해당 Object의 "<<" 부터 "endobj"까지의 모든 데이터가 된다. 즉, 두번째 값에는 Attribute와 stream 데이터를 포함한 데이터이므로 키워트 "stream"을 기준으로 분류하는 것이 바람직하다. 단, stream이 없을 수 있다는 점을 감안해야 한다.

0x40. Cross-reference table : xref table (참조 테이블)

참조 테이블은 PDF 파일 포맷 내부의 Object에 대한 정보를 담고 있다. 특히 Object의 시작 위치 정보를 담고 있는 만큼 PDF 파일을 분석할 때 많은 도움을 얻을 수 있다. 하지만 xref table 이 없더라도 PDF 파일이 실행되는데 아무런 문제가 되지 않은 만큼 xref table이 있을 경우 참조할 수 있지만 없을 경우 xref table을 찾는 과정 또한 불필요한 로직이 될 수 있어 개발 모듈에는 포함하지 않았다. 하지만 기본 구조를 알아두면 도움이 될 것 같아 정보를 남긴다.

* 포인트 : xref table이 존재하지 않아도 PDF 파일을 실행하고 그 내용을 확인하는데는 아무런 문제가 없다.

0x50. Trailer (트레일러)

PDF 파일의 Body에 있는 오브젝트 중 가장 먼저 실행되어야 할 오브젝트 (루트 오브젝트, Root Object)의 시작 위치와 Cross-Reference Table의 시작 위치를 갖고 있다. 또한 "trailer" 문자열로 시작하고 "%%EOF"로 끝난다.

* 포인트 : 트레일러가 존재하지 않아도 정상동작이 가능하며 마지막 시그니처 또한 "%%EOF" 가 아닌 "%EOF"인 경우도 다수 존재한다.

  • trailer << /Root 1 0 R /Size 10 >> startxref 8061 %EOF
    • /Root : 루트 오브젝트의 번호
    • /Size : Cross-Reference Table 항목 수
    • /Prev : 이전 Cross-Reference Table 의 위치
def GetTrailer(fname):
    f = file(fname, "rb")
    pdf = f.read()
    f.close()

    pdf_sig = "trailer.*?<<(.*?)>>.*?%EOF"
    return re.findall(pdf_sig, pdf)

위의 코드를 실행하면 어떤 결과가 출력될까? ( 단, trailer를 찾는 정규 표현식은 정상적으로 동작한다. ) 코드를 직접 실행해 보면 어떤 경운 출력이 정상적이고 어떤 경우는 출력이 되지 않는다. 하지만 프로그램은 정상적으로 동작하고 있다. 무엇인 문제일까?

trailer를 찾는 것은 PDF의 앞부분에 있는 시그니처와 찾는 방식은 동일하지만 주의해야 하는 부분이 있다. 바로 파일 크기이다. PDF의 버전 정보를 담고 있는 시그니처는 항상 파일의 가장 앞 부분의 8Bytes를 비교한다. 즉, 파일의 크기와 상관없이 파일의 시작 부터 8Bytes만 정규 표현식의 비교 대상이 되기 때문에 속도에 아무런 문제가 없다. 하지만 "trailer ~ %EOF"를 찾는 방식은 조금 다르다. 파일의 끝 부분에 위치하는 문자열을 파일의 시작부터 찾기 때문이다. 따라서 파일 하위에서 trailer를 찾도록 수정해야 된다.

* 포인트 : Trailer가 존재할 경우 파일 하위에 존재하는 것이 일반적이므로 찾는 방법도 파일 하위부터 검색해야 한다.

과연 trailer는 몇개까지 존재할 수 있을까?

PDF 파일은 업데이트 방식이 독특하다. PDF 파일은 파일 내용이 갱신될 경우 파일의 후위에 추가하는 형태를 띈다. 즉, 처음 만들어진 1.pdf 파일에 내용을 추가해 2.pdf 파일을 만들면 1.pdf 파일의 바이너리상 마지막에 2.pdf를 위해 추가한 내용이 덧붙여진다. 따라서 PDF 파일 내에 trailer는 여러개 존재할 수 있다.

* 포인트 : Trailer는 0개 ~ 다수개 존재할 수 있다.

실제 PDF Specification 문서 역시 이러한 형태로 작성되어 있다.

0x60. En/Decode (암복호화)

Object의 Attribute는 폰트, 참조 Object 정보, 하위 Stream의 크기등 많은 정보를 갖고 있다. 이 중 stream 데이터에 대한 암복호화 정보도 갖고 있다. Object의 stream 암호화를 지원하는 방식은 총 10가지이다.

하지만 이 중 CCITTFaxDecode 와 Crypt는 사용하지 않는 방식이고 JBIG2Decode 와 DCTDecode, JPXDecode는 PDF 파일내에 첨부된 Jpeg 파일에 국한되어 사용되는 복호화 알고리즘이다. 이러한 복호화 알고리즘은 Object의 Attribute 내에 정의되어 있는 것이 일반적이며 "/Filter" 다음에 관련 정보가 위치한다. (FlateDecode와 같은 복호화 알고리즘은 Attribute 내에 표기되지 않은 경우도 존재하며 "/Filter" 키워드가 제외된 채 바로 복호화 알고리즘이 나오기도 한다.) 그리고 "/Size"의 속성값이 stream 데이터의 복호화 크기가 된다. (하지만 "/Size" 키워드는 없을 수 있으며 "/Size" 속성값이 Stream 데이터와 일치하지 않을 수 있다.)

In [7]:
"""
[refered] http://pyew.googlecode.com/hg-history/e984a67f8cf1a564b97187171c237da98ce5b255/plugins/pdf.py
"""

import zlib
import binascii
import cStringIO

def FlateDecode(in_stream):
    return zlib.decompress(in_stream.strip())

def ASCII85Decode(in_stream):
    out_stream = ""
    group = []
    x = 0
    hitEod = False
    # remove all whitespace from data
    stream = [y for y in in_stream if not (y in ' \n\r\t')]
    while not hitEod:
        c = stream[x]
        if len(out_stream) == 0 and c == "<" and stream[x+1] == "~":
            x += 2
            continue
        # elif c.isspace():
        #    x += 1
        #    continue
        elif c == 'z':
            assert len(group) == 0
            out_stream += '\x00\x00\x00\x00'
            continue
        elif c == "~" and stream[x+1] == ">":
            if len(group) != 0:
                # cannot have a final group of just 1 char
                assert len(group) > 1
                cnt = len(group) - 1
                group += [ 85, 85, 85 ]
                hitEod = cnt
            else:
                break
        else:
            c = ord(c) - 33
            assert c >= 0 and c < 85
            group += [ c ]
        if len(group) >= 5:
            b = group[0] * (85**4) + \
                group[1] * (85**3) + \
                group[2] * (85**2) + \
                group[3] * 85 + \
                group[4]
            assert b < (2**32 - 1)
            c4 = chr((b >> 0) % 256)
            c3 = chr((b >> 8) % 256)
            c2 = chr((b >> 16) % 256)
            c1 = chr(b >> 24)
            out_stream += (c1 + c2 + c3 + c4)
            if hitEod:
                retval = out_stream[:-4+hitEod]
            group = []
        x += 1
    return out_stream

def ASCIIHexDecode(in_stream):
    return binascii.unhexlify(''.join([c for c in in_stream if c not in ' \t\n\r']).rstrip('>'))

def RunLengthDecode(in_stream):
    out_stream = ""
    f = cStringIO.StringIO(in_stream)
    runLength = ord(f.read(1))
    while runLength:
        if runLength < 128:
            out_stream += f.read(runLength + 1)
        if runLength > 128:
            out_stream += f.read(1) * (257 - runLength)
        if runLength == 128:
            break
        runLength = ord(f.read(1))
#    return sub(r'(\d+)(\D)', lambda m: m.group(2) * int(m.group(1)), data)
    return out_stream

def LZWDecode(in_stream):
    return ''.join(LZWDecoder(cStringIO.StringIO(in_stream)).run())

class LZWDecoder(object):
    def __init__(self, fp):
        self.fp = fp
        self.buff = 0
        self.bpos = 8
        self.nbits = 9
        self.table = None
        self.prevbuf = None
        return

    def readbits(self, bits):
        v = 0
        while 1:
            # the number of remaining bits we can get from the current buffer.
            r = 8-self.bpos
            if bits <= r:
                # |-----8-bits-----|
                # |-bpos-|-bits-|  |
                # |      |----r----|
                v = (v<<bits) | ((self.buff>>(r-bits)) & ((1<<bits)-1))
                self.bpos += bits
                break
            else:
                # |-----8-bits-----|
                # |-bpos-|---bits----...
                # |      |----r----|
                v = (v<<r) | (self.buff & ((1<<r)-1))
                bits -= r
                x = self.fp.read(1)
                if not x: raise EOFError
                self.buff = ord(x)
                self.bpos = 0
        return v

    def feed(self, code):
        x = ''
        if code == 256:
            self.table = [ chr(c) for c in xrange(256) ] # 0-255
            self.table.append(None) # 256
            self.table.append(None) # 257
            self.prevbuf = ''
            self.nbits = 9
        elif code == 257:
            pass
        elif not self.prevbuf:
            x = self.prevbuf = self.table[code]
        else:
            if code < len(self.table):
                x = self.table[code]
                self.table.append(self.prevbuf+x[0])
            else:
                self.table.append(self.prevbuf+self.prevbuf[0])
                x = self.table[code]
            l = len(self.table)
            if l == 511:
                self.nbits = 10
            elif l == 1023:
                self.nbits = 11
            elif l == 2047:
                self.nbits = 12
            self.prevbuf = x
        return x

    @classmethod
    def run(self):
        while 1:
            try:
                code = self.readbits(self.nbits)
            except EOFError:
                break
            x = self.feed(code)
            yield x
        return

0x70. 분석정보

0x90. Bug

0x99. 참고

댓글

가장 많이 본 글