Return Address Verifier

이번 장에서 Zigzi 를 활용하여 Runtime 에 함수의 복귀주소를 검증하는 기법인 Return Address Verifier 를 구현해보고 이를 통해서 Zigzi 를 기반으로 Binary Instrumentation 을 활용한 기능을 Binary 에 추가하는 방법을 살펴보겠습니다.

PE

먼저 Instrumentation 기능을 사용하기 위해 PEInstrument 모듈을, Binary rewriting 기능을 사용하기 위해 File ManagerPEManager 모듈을 import 합니다.

from PEInstrument import *
from PEManager import *
from WindowAPIHelper import *

또한 Return Address Verifier 를 통해 함수의 복귀주소가 변조됐을 시에 이를 시각적으로 보여주기 위해서 MessageBoxA 를 사용할 목적으로 WindowAPIHelper 모듈 또한 import 합니다.

# PE Manager
pe_manager = PEManager(filename)
# add api
window_api_helper = WindowAPIHelper(pe_manager)
message_box_fn_rva = window_api_helper.add_message_box()
# set new instrumentation
pe_instrument = PEInstrument(pe_manager)

# instrument return address verifier feature to binary
do_return_address_verifier(pe_instrument, pe_manager, message_box_fn_rva)

이제 원하는 Binary의 경로와 이름으로 생성된 PEManager와 WindowAPIHelper를 통해 PEInstrument 와 MessageBox API의 Relative Virtual Address 값을 얻습니다.

Return Address Verifier 기능을 구현하는 과정은 아래에서 다룹니다.

def do_return_address_verifier(pe_instrument, pe_manager, fn_rva):
    simple_instrument_error_handler(pe_instrument, pe_manager, fn_rva)

    # case of relative branch instruction
    pe_instrument.register_after_relative_branch(
        simple_instrument_return_address_at_after_branch
    )
    # case of indirect branch instruction
    pe_instrument.register_after_indirect_branch(
        simple_instrument_return_address_at_after_branch
    )

    # case of return instruction
    pe_instrument.register_pre_return(
        simple_instrument_return_address_verifier_at_pre_return
    )
    pe_instrument.do_instrument()

Return Address Verifier 을 구현하는 과정은 크게 세 단계로 이루어집니다. 첫 번째, 과정으로 함수의 복귀주소 검증이 실패했을시 이를 시각적으로 보여주기 위해서 MessageBox 를 띄우는 API 호출 로직을 추가하는 과정입니다.

def simple_instrument_error_handler(pe_instrument, pe_manager, fn_rva):
    global code_rva
    fn_va = pe_manager.get_abs_va_from_rva(fn_rva)

    allocation = pe_instrument.falloc(0x1000)
    caption = "Zigzi"
    text = "Failed to Verifying Return Address."

    allocation_va = allocation.get_va()
    caption_start_pos = 0
    text_start_pos = 0x100
    allocation[caption_start_pos:len(caption)] = caption
    allocation[text_start_pos:len(text)] = text

    code = ("push 0;"   # UINT    uType
            "push {};"   # LPCTSTR lpCaption,
            "push {};"   # LPCTSTR lpText,
            "push 0;"   # HWND    hWnd
            "call [{}];").format(allocation_va + caption_start_pos,
                                 allocation_va + text_start_pos,
                                 fn_va)
    code_rva = pe_instrument.append_code(code)
    pe_manager.register_rva_to_relocation(code_rva
                                          + 3   # push 0; push
                                          )
    pe_manager.register_rva_to_relocation(code_rva
                                          + 3   # push 0; push
                                          + 5   # lpCaption;push
                                          )
    pe_manager.register_rva_to_relocation(code_rva
                                          + 3   # push 0; push
                                          + 5   # lpCaption;push
                                          + 4   # lptext
                                          + 2   # push 0;
                                          + 2   # call
                                          )

첫 번째 과정은, 1. MessageBox 에 인자로 사용할 Text ZigziFailed to Verifying Return Address. 를 Binary에 삽입 합니다. 2. 1에서 Text가 삽입된 주소와 MessageBox API의 RVA(Relative Virtual Address)를 기반으로 MessageBox를 호출하는 로직을 어셈블리어로 코딩하여 추가합니다. 3. Text들과 MessageBox의 RVA는 상대주소이므로 Relocation 목록에 추가합니다. 로 이루어지게 됩니다.

위의 과정을 통해서 MessageBox를 호출하는 함수를 Binary에 추가했고 해당 함수의 RVA를 획득합니다.

def simple_instrument_return_address_at_after_branch(instruction):
    code = ("prefetch [{0}]".format(instruction.address
                                    + instruction.size
                                    + 0x1000))
    hex_code = binascii.hexlify(code).decode('hex')
    try:
        # Initialize engine in X86-32bit mode
        ks = Ks(KS_ARCH_X86, KS_MODE_32)
        encoding, count = ks.asm(hex_code)
        return encoding, count
    except KsError as ex:
        print("ERROR: %s" % ex)
    return None, 0

우리가 이미 알다시피 함수를 호출하는 경우에 복귀할 주소는 해당 함수를 호출한 지점의 다음 명령어입니다. 때문에 분기 명령어 계통의 다음 주소는 항상 복귀할 주소입니다.

이 원리에 따라 모든 분기 명령어의 다음에 해당 분기 명령어의 다음 주소(복귀주소) 에 해당 주소의 값을 저장하는 공간을 만듭니다.

실행 간에 영향을 미치지 않도록 이 주소의 값을 저장하기 위해서 우리는 prefetch 명령어를 사용할 것 입니다.

def simple_instrument_return_address_verifier_at_pre_return(instruction):
    global code_rva
    code = (
        "push ecx;"             # store value
        "mov ecx, [esp+4];"     # load return address to ecx
        "cmp [ecx+3], ecx;"     # compare return address with RAV
        "jne {};"       # if not equal, jump to error handler
        "pop ecx;"              # recover value
    ).format(code_rva - instruction.address - 0x1000
             + 0xF  # instruction size till end of instruction.
             )
    hex_code = binascii.hexlify(code).decode('hex')
    try:
        # Initialize engine in X86-32bit mode
        ks = Ks(KS_ARCH_X86, KS_MODE_32)
        encoding, count = ks.asm(hex_code)
        return encoding, count
    except KsError as ex:
        print("ERROR: %s" % ex)
    return None, 0

이제 함수가 호출된 후에 복귀를 하는 시점에, 스택에 저장된 복귀할 주소와 해당 주소에 우리가 instrumentation 한 명령어 prefetch 의 operand 주소를 비교하면 복귀주소의 검증을 수행할 수 있습니다.

결과는 다음과 같네요.

시연

Warning

위의 동영상은 Visual Studio 2015에서 작성된 window32 console 프로그램이며 시연의 편의를 위해 일부 수정하였습니다.