티스토리 뷰
들어가기 전에..
바쁜 현대인들을 위한 3줄요약
1. 일이 영 손에 안잡혔음.
2. VMP 샘플인데 호출하는 Windows API 가 그지같이 되어있어서 스크립트짬.
3. 동적으로 찾아주면 편한걸 굳이 정적으로 추적해서 코드를 패치해주는 스크립트를 만듬 <- 병신
VWAAD (VMProtect Windows API Address Decoder) : https://github.com/saweol/vwaad
개요
최근 이슈 추적 중 사이버안전국을 사칭해 악성코드 설치를 유도하는 스피어 피싱 이메일을 발견했다.
피싱메일에는 "사이버안전국.egg" 라는 egg포맷의 압축파일이 하는데 메일 본문에서 이를 "랜섬웨어 공격을 방지하기 위한 컴퓨터검사프로그램" 으로 소개하고있다.
eml : A470BC9E090097A2BB9EA14979F8C8BD
사이버안전국.egg : 4FB6711BEB27CC5936DC3AF41D3386E7
사이버안전국.exe : 096A99A0B465EB843F5720EB1C8D199B
당연한 이야기지만 압축을 풀고 실행하면 악성코드가 설치된다.
악성코드 기본 정보
압축을 해제하면 "사이버안전국.exe"라는 파일이 나온다. 이 파일은 리소스 영역에 존재하는 압축파일을 %temp%\st[randomNumber] 폴더에 res2.tmp라는 이름으로 드롭하고 rundll32.exe를 통해 Export 함수인 Run을 실행시켜주는 역할을 한다.
res2.tmp 파일은 VMProtect로 패킹되어 있지만 별다른 안티 디버깅이 존재하지 않는지 실행하면 .text영역에 패킹이 해제된 코드가 나온다. (악성코드 분석은 더 이상 진행하지 않는다. 분석중에 엄한데 꽂혀버려서...)
VMProtect Api Address Redir 분석
아래 그림에서 .text섹션에 있는 call문이 Windows API의 주소가 아닌 .vmp0 영역의 주소를 가리키고 있다.
.vmp0 섹션에서 더미코드들을 지나고 나면 PUSH/RETN을 통해 CreateThread 함수를 호출한다.
즉 .text:0x10001F74 : call 0x10073801 함수는 결국 call CreateThread 임을 의미한다. 저런 더러운 CALL문이 한둘도 아니고 수작업으로 다 찾아서 바꿔주기 귀찮아 찾아주는 스크립트를 짜보자는 생각이 들었다.
더러운 .vmp0 섹션을 분석해보니 retn시 함수로 점프하는 주소를 PUSH하고 RETN으로 함수를 호출된다는걸 확인했다. PUSH하기 조금 전으로 돌아가면 0x1007409C위치에서 ESI의 값과 [ESP+58]을 xchg로 바꾼다. 이는 함수시작에서 RETN까지의 과정에서 ESI 값을 추적하면 실제로 호출하는 API의 주소를 가져올 수 있을것으로 추정했다. ESI 추적 고고.
1007409C 877424 58 XCHG DWORD PTR SS:[ESP+58],ESI //ESI에 있는 값을 ESP+58 이랑 바꿈 |
CALL 시작부터 RETN까지의 모든 실행코드를 추적했다.
10073801 9C PUSHFD 10073DB9 51 PUSH ECX 10087FCD 897424 4C MOV DWORD PTR SS:[ESP+4C],ESI 1007922A BE DB270010 MOV ESI,3.100027DB 10076BE4 54 PUSH ESP 10083A84 8BB6 C3250800 MOV ESI,DWORD PTR DS:[ESI+825C3] 1006728C /E9 3CDB0100 JMP 3.10084DCD 10084DCD 8DB6 ED8670D4 LEA ESI,DWORD PTR DS:[ESI+D47086ED] 10073ECD E8 CA010000 CALL 3.1007409C 1007409C 877424 58 XCHG DWORD PTR SS:[ESP+58],ESI |
볼드 처리된 값들이 ESI 레지스터를 사용하는 코드들과 API호출 코드들이다. 더러운 더미코드들은 집어치우고 볼드처리된 중요한 값들만 가져와보자
10073802 66:0FB6F1 MOVZX SI,CL |
ESI 레지스터를 사용하는 코드들 중 볼드 처리된 0x1007922A 위치에서 MOV ESI,3.100027DB 명령으로 ESI에 상수값을 넣어준다. 즉 저 위치 이전에 ESI를 사용하는코드들은 모두 더미코드이다. 더미코드를 또다시 집어치우고 중요한 코드만 보자.
1007922A BE DB270010 MOV ESI,3.100027DB 10083A84 8BB6 C3250800 MOV ESI,DWORD PTR DS:[ESI+825C3] 10084DCD 8DB6 ED8670D4 LEA ESI,DWORD PTR DS:[ESI+D47086ED] 1007409C 877424 58 XCHG DWORD PTR SS:[ESP+58],ESI 100740AC FF7424 58 PUSH DWORD PTR SS:[ESP+58] 100740B0 C2 5C00 RETN 5C |
코드가 심플하게 추려졌다. 볼드처리된 0x1007409C 위치에서 XCHG를 수행할때 이미 ESI에는 API의 주소값이 들어있다. 즉 실제 API주소를 가져오는 코드는 XCHG 명령위에 3개의 코드이다. 말로 풀어보자면...
1. MOV ESI,3.100027DB : 레지스터에 상수값을 넣음
2. MOV ESI,DWORD PTR DS:[ESI+825C3] : 레지스터에 특정값을 더해 주소값을 구해 그 위치의 데이터를 레지스터에 로드
3. LEA ESI,DWORD PTR DS:[ESI+D47086ED] : 로드한 값에 상수값을 더해 최종 API 주소를 구함
과 같이 진행된다. 위 세가지 형태를 편의를 위해 가젯이라 부르자. 자동화를 위해 가젯을 정의하면
gadget 1 : MOV R32, CONST
gadget 2 : MOV R32, [R32 + CONST]
gadget 3 : LEA R32, [R32+CONST]
위와같다. 이대로 뽑아내면 주소를 모두 찾아낼 수 있겠지..라는 안일한 생각으로 다른함수를 보는데 jnz, je, jno같은 보건분기문이 나왔다.
CALL이나 JMP같은 분기문은 그냥 따라가면되지만 조건분기문은 특정 조건에따라 이동하는 위치가 달라지므로 잘못이동하면 X될수도 있다는 생각이 들어 다른함수들을 추적해보았다.
10001F8A . E8 DE200700 CALL 3.1007406D -> START 1007406D 0FB6D8 MOVZX EBX,AL 10072A45 90 NOP 100645A1 8D1C4D 17C82EFA LEA EBX,DWORD PTR DS:[ECX*2+FA2EC817] 1008490C 875C24 2C XCHG DWORD PTR SS:[ESP+2C],EBX |
|
//JMP할 경우 100751CC 60 PUSHAD 10060022 8B9B 94890700 MOV EBX, DS:[EBX+78994] 10077F58 8D9B 47BB6CDA LEA EBX,DS:[EBX+DA6CBB47] 10077F65 FF3424 PUSH SS:[ESP] 100849E3 875C24 24 XCHG SS:[ESP+24],EBX |
//JMP하지 않을 경우 1008491F 53 PUSH EBX |
감사하게도 조건분기 여부와 관계없이 동일한 가젯이 사용되므로 아무 문제가 없다.
API주소를 가져오는 방식에 대해 분석을 마쳤으니 스크립트가 어떻게 동작할지를 정의하자.
1. 에뮬레이터를 사용하지 않고 스태틱하게 뽑아낼 예정
2. 메모리 덤프 기반이므로 IDAScript로 작성
3. .text 섹션에서 .vmp0로 CALL 하는 모든 위치 정보를 가져옴
4. 3에서 가져온 위치정보를 기반으로 CALL문 내부 코드를 추적해 RETN까지 진행하며 가젯을 찾음.
5. 찾은 가젯을 기반으로 원본 주소를 복구.
VMProtect Api Address Decoder
.text에서 .vmp0로 CALL하는 모든 위치정보를 가져오자. getVmpCallList 함수에 섹션이름들을 넣어 섹션 시작, 끝, 사이즈를 가져온다. .text섹션의 어셈블리코드를 라인별로 디스어셈블해 CALL .vmp0인 것들을 리스트로 반환해주도록 하자.
'''
Collect All calls thet .vmp0 from .text
arg : targetSeg Address, vmpSeg Address
return : True
'''
def getVmpCallList(self, targetSeg, vmpSeg):
idx = 0
addr = 0
dis_ea, dis_end, dis_size = self.getSegName(targetSeg)
vmp_ea, vmp_end, vmp_size = self.getSegName(vmpSeg)
while dis_ea < dis_end:
if idc.GetDisasm(dis_ea)[:4] == "call":
addr = get_operand_value(dis_ea,0)
if self.checkAddrInSegment(addr, vmp_ea, vmp_end) is True:
idx = idx + 1
self.vmp_list.append(addr)
self.call_list.append(dis_ea)
dis_ea = idc.NextHead(dis_ea)
return True
기능점검
정상적으로 잘 가져온다.
CALL 리스트를 찾았으니 .vmp0의 함수별 트레이싱 코드를 작성해야한다. 레지스터/스택 값의 변화를 추적할 필요가 없고 조건분기를 신경쓰지 않아도되니 조건 분기를 신경 쓰지 않아도 되니 스태틱한 방식으로 트레이싱을 진행한다. 함수내부에서 EIP를 증가시키면서 어셈블리코드를 뽑아와 가젯여부를 확인한다. CALL/JMP의 경우 EIP값을 변경해주고 지정된 gadget과 동일한 형태일 경우 리스트로 저장했다.
'''
Function Tracing without conditional branching
arg : function address, debug falg
return : gadget list / False
'''
def functionTracer(self, addr, debug = False):
idx = 0
depth = self.depth
result = []
ep = addr
while idx < depth:
idx = idx + 1
if debug is True:
print( '{0} {1} {2} {3}'.format(idx, hex(addr), idc.GetDisasm(addr), hex(get_operand_value(addr,0)) ))
if idc.GetDisasm(addr)[:4] == "retn":
break
elif idc.GetDisasm(addr)[:3] == "jmp" or idc.GetDisasm(addr)[:4] == "call":
#print get_operand_value(addr,0)
addr = get_operand_value(addr,0)
elif self.findFirstGadget(addr) == True:
if len(result) > 0:
result = []
result.append(self.getGadget(addr, ep, 1))
addr = idc.NextHead(addr)
elif len(result) == 1 and self.findSecondGadget(addr, result[0]['reg']) == True:
#input gadget
result.append(self.getGadget(addr,ep, 2))
addr = idc.NextHead(addr)
elif len(result) == 2 and self.findThirdGadget(addr, result[0]['reg']) == True:
result.append(self.getGadget(addr,ep, 3))
addr = idc.NextHead(addr)
else:
addr = idc.NextHead(addr)
if len(result) == 3:
return result
else:
return False
테스트 실행결과가 만족스럽다.
추적에 실패한 함수들도 출력해줬다.
실패한 친구들을 위해 Debug옵션을 넣어 어셈블리 라인으로 추적가능하도록 작성했다.
CALL문도 다 뽑았고. 가젯리스트도 다 뽑았다. 이제 가젯들의 최종 결과들을 계산해주면된다. 별로 설명할게 없다.....
''' decode address from gadget arg : None return : None ''' def decodeAddress(self): idx = 0 data = 0 for gadget in self.gadget_list: for y in gadget: if y['flow'] == 2: data = next(GetDataList(data + y['const'],1, itemsize=4)) else: data = data + y['const'] data = data & 0xFFFFFFFF self.decoded_api.append(dict( \ {'original_addr' : gadget[0]['entrypoint'], \ 'original_assembly' : idc.GetDisasm(gadget[0]['entrypoint']), \ 'decoded_address' : data})) data = 0
일련의 미친짓들을 거치고나면 아래처럼 .vmp0 섹션으로 CALL하는 모든 주소가 디코드 되어 나온다.
기능은 모두 완성했다. 하지만 저 주소들을 Ollydbg에서 한땀한땀 입력해주기 귀찮으니까.. OllyScript로 패치해주는 함수를 만들어주자.
''' patch script for ollydbg arg : None return : None ''' def patchOllyScript(self): for gadget in self.decoded_api: print ('asm {0}, \"call {1}\"'.format(hex(gadget['original_addr']).rstrip("L").lstrip("0x"), hex(gadget['decoded_address']).rstrip("L")))
기능은 정상적으로 동작한다.
다 만들었으니 테스트해보자.
잘 동작한다. 이쁜건 IDA로도 봐줘야 한다. IDA에서 다시 보자.
후기
개발은 잘못하는데 필요성은 점점 느끼고만 있다가 뜬금없이 페이스북에 올라온 PPT를 보고 자극받아 만들어보았다.
(일하기 싫음 80%/ 자극받음 20%)
(ref : https://www.slideshare.net/dkswognsdi/ss-142458326?fbclid=IwAR3AN8PLXrJpRy-rZf7E7NkyAGlVMkyGqqFqH9-8yLsI2nSHH9Dh8qoKzLA)
그럴 리는 없겠지만 이런 변태 짓에 관심이 있는 분들이 계실까 봐 일단 github에 올려두었다.
(https://github.com/saweol/vwaad)
그럼이만 술마시러 뿅!
PS 1. 코드가 매우 그지같으니 주의하기 바람.
PS 2. 코드 고쳐주셔도 됩니다. 보고 저도 많이배우겠습니다.
PS 3. 시험기간이 다가오니 별게 다 재미있어지는 학생의 기분이다. 이제 정신차리고 밀린일들부터 처리하자 ㅠㅠ