├── go.bat ├── README.md ├── write.py └── ida-test.py /go.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | for %%i in ("C:\Users\es\Desktop\reloc-patch\*.dll") DO ( 3 | 4 | echo %%i 5 | cd "C:\Program Files\IDA Pro 7.5" 6 | ida.exe -A -S"C:\Users\es\Desktop\ida-test.py" %%i 7 | cd "C:\Users\es\Desktop\reloc-patch\" 8 | ) 9 | 10 | move *.dll \\vmware-host\Shared Folders\fdoemcd\ida-test\test-files\ 11 | move *.relocs.txt \\vmware-host\Shared Folders\fdoemcd\ida-test\test-files\ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PENecro 2 | 3 | This project is based on "Enabling dynamic analysis of Legacy Embedded Systems in full emulated environment", published on hardwear.io USA 2021 [1] and HITCON 2021 [2]. 4 | 5 | ## Introduction 6 | 7 | See slides [3]. 8 | 9 | ## Prerequisites 10 | 11 | This PoC is based on IDAPython, but using radare2 and similiar tools can achieve the same results. 12 | 13 | ## Usage 14 | 15 | 1. Extract PE from CE firmware 16 | 2. Remove all extra sections (e.g. debug) from PE 17 | 3. Use IDA in a way similiar to `go.bat` to create `n.dll.relocs.txt` 18 | 4. Use `write.py test.dll test.relocs.txt` to write relocs back to the PE 19 | -------------------------------------------------------------------------------- /write.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pefile 3 | import mmap 4 | import os 5 | import reloc_build 6 | import sys 7 | 8 | 9 | def cal_size_of_raw_data(file_alignment, size): 10 | # return (((size + file_alignment - 1) / file_alignment) * file_alignment) 11 | return file_alignment * math.ceil(size / file_alignment) 12 | 13 | 14 | def cal_virtual_size(size): 15 | return size 16 | 17 | 18 | def align(size, file_alignment): 19 | return file_alignment * math.ceil(size / file_alignment) 20 | # return (((val_to_align + alignment - 1) / alignment) * alignment) + 1 21 | 22 | 23 | def run(exe_path, reloc_path): 24 | pe = pefile.PE(exe_path) 25 | reloc = pe.OPTIONAL_HEADER.DATA_DIRECTORY[5] 26 | if reloc.VirtualAddress: 27 | print("Reloc found in file! Skipping {}".format(exe_path)) 28 | return 29 | 30 | payload = reloc_build.run(reloc_path, pe.OPTIONAL_HEADER.ImageBase) 31 | 32 | # print("[*] 0: Resize the Executable") 33 | original_size = os.path.getsize(exe_path) 34 | # print("\t[+] Original Size = %d, Payload Size = %d" % (original_size, len(payload))) 35 | fd = open(exe_path, 'a+b') 36 | _map = mmap.mmap(fd.fileno(), 0, access=mmap.ACCESS_WRITE) 37 | file_alignment = pe.OPTIONAL_HEADER.FileAlignment 38 | _map.resize(original_size + 0x28 + len(payload)) 39 | _map.flush() 40 | _map.close() 41 | fd.close() 42 | 43 | pe = pefile.PE(exe_path) 44 | # print("\t[+] New Size = %d bytes\n" % os.path.getsize(exe_path)) 45 | 46 | # print("[*] 1: Add the New Section Header") 47 | 48 | number_of_section = pe.FILE_HEADER.NumberOfSections 49 | last_section = number_of_section - 1 50 | file_alignment = pe.OPTIONAL_HEADER.FileAlignment 51 | section_alignment = pe.OPTIONAL_HEADER.SectionAlignment 52 | new_section_offset = (pe.sections[number_of_section - 1].get_file_offset() + 40) 53 | 54 | # Look for valid values for the new section header 55 | raw_size = cal_size_of_raw_data(file_alignment, len(payload)) 56 | virtual_size = cal_virtual_size(len(payload)) 57 | 58 | raw_offset = int(align((pe.sections[last_section].PointerToRawData + 59 | pe.sections[last_section].SizeOfRawData), 60 | file_alignment)) 61 | 62 | virtual_offset = int(align((pe.sections[last_section].VirtualAddress + 63 | pe.sections[last_section].Misc_VirtualSize), 64 | section_alignment)) 65 | 66 | characteristics = 0x42000040 67 | # Section name must be equal to 8 bytes 68 | name = ".reloc" + (2 * '\x00') 69 | 70 | # Create the section 71 | # Set the name 72 | pe.set_bytes_at_offset(new_section_offset, name.encode('ascii')) 73 | # print("\t[+] Section Name = %s" % name) 74 | # Set the virtual size 75 | pe.set_dword_at_offset(new_section_offset + 8, virtual_size) 76 | # print("\t[+] Virtual Size = %s" % hex(virtual_size)) 77 | # Set the virtual offset 78 | pe.set_dword_at_offset(new_section_offset + 12, virtual_offset) 79 | # print("\t[+] Virtual Offset = %s" % hex(virtual_offset)) 80 | # Set the raw size 81 | pe.set_dword_at_offset(new_section_offset + 16, raw_size) 82 | # print("\t[+] Raw Size = %s" % hex(raw_size)) 83 | # Set the raw offset 84 | pe.set_dword_at_offset(new_section_offset + 20, raw_offset) 85 | # print("\t[+] Raw Offset = %s" % hex(raw_offset)) 86 | # Set the following fields to zero 87 | pe.set_bytes_at_offset(new_section_offset + 24, (b"\x00" * 12)) 88 | # Set the characteristics 89 | pe.set_dword_at_offset(new_section_offset + 36, characteristics) 90 | # print("\t[+] Characteristics = %s\n" % hex(characteristics)) 91 | 92 | # STEP 0x03 - Modify the Main Headers 93 | # print("[*] 2: Modify the Main Headers") 94 | pe.FILE_HEADER.NumberOfSections += 1 95 | # print("\t[+] Number of Sections = %s" % pe.FILE_HEADER.NumberOfSections) 96 | pe.OPTIONAL_HEADER.SizeOfImage = virtual_size + virtual_offset 97 | # print("\t[+] Size of Image = %d bytes" % pe.OPTIONAL_HEADER.SizeOfImage) 98 | 99 | pe.write(exe_path) 100 | 101 | pe = pefile.PE(exe_path) 102 | number_of_section = pe.FILE_HEADER.NumberOfSections 103 | last_section = number_of_section - 1 104 | 105 | reloc_rva = pe.sections[last_section].VirtualAddress 106 | # print("\t[+] Relocation Directory RVA = %s" % hex(reloc_rva)) 107 | # print("\t[+] Relocation Directory Size = %s" % hex(len(payload))) 108 | 109 | pe.OPTIONAL_HEADER.DATA_DIRECTORY[5].Size = len(payload) 110 | pe.OPTIONAL_HEADER.DATA_DIRECTORY[5].VirtualAddress = reloc_rva 111 | 112 | # print("[*] STEP 3: Inject the payload in the New Section") 113 | raw_offset = pe.sections[last_section].PointerToRawData 114 | if raw_size > len(payload): 115 | payload.extend(bytearray(raw_size - len(payload))) 116 | pe.set_bytes_at_offset(raw_offset, bytes(payload)) 117 | print("\t[+] payload wrote in the new section {}".format(exe_path)) 118 | pe.write(exe_path) 119 | 120 | 121 | if __name__ == '__main__': 122 | run(sys.argv[1], sys.argv[2]) 123 | -------------------------------------------------------------------------------- /ida-test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import idautils 5 | import idaapi 6 | import idc 7 | import ida_funcs 8 | import ida_ua 9 | import ida_bytes 10 | import ida_nalt 11 | import ida_ida 12 | import ida_auto 13 | import ida_pro 14 | import pefile 15 | 16 | 17 | def is_in_original_range(addr): 18 | start = idaapi.get_imagebase() 19 | end = ida_ida.inf_get_max_ea() 20 | return start < addr and addr < end 21 | 22 | 23 | class ForbiddenRangeFinder(object): 24 | 25 | def __init__(self, filename): 26 | self.pe = pefile.PE(filename) 27 | self.forbidden_area = [ 28 | 'IMAGE_DIRECTORY_ENTRY_EXPORT', 29 | 'IMAGE_DIRECTORY_ENTRY_IMPORT', 30 | 'IMAGE_DIRECTORY_ENTRY_RESOURCE', 31 | 'IMAGE_DIRECTORY_ENTRY_EXCEPTION', 32 | 'IMAGE_DIRECTORY_ENTRY_SECURITY', 33 | 'IMAGE_DIRECTORY_ENTRY_BASERELOC', 34 | 'IMAGE_DIRECTORY_ENTRY_DEBUG', 35 | 'IMAGE_DIRECTORY_ENTRY_COPYRIGHT', 36 | 'IMAGE_DIRECTORY_ENTRY_GLOBALPTR', 37 | 'IMAGE_DIRECTORY_ENTRY_TLS', 38 | 'IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG', 39 | 'IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT', 40 | 'IMAGE_DIRECTORY_ENTRY_IAT', 41 | 'IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT', 42 | 'IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR', 43 | 'IMAGE_DIRECTORY_ENTRY_RESERVED' 44 | ] 45 | self.image_base = self.pe.OPTIONAL_HEADER.ImageBase 46 | 47 | def get_import_by_names(self): 48 | pe = self.pe 49 | bits = 64 if pe.PE_TYPE == pefile.OPTIONAL_HEADER_MAGIC_PE_PLUS else 32 50 | ordinal_flag = 2 ** (bits - 1) 51 | text_ranges = [] 52 | for module in pe.DIRECTORY_ENTRY_IMPORT: 53 | ilt = pe.get_import_table(module.struct.OriginalFirstThunk) 54 | for i in range(len(ilt)): 55 | i = ilt[i] 56 | hint_rva = i.AddressOfData 57 | if hint_rva: 58 | if hint_rva & ordinal_flag: 59 | pass 60 | else: 61 | fun_name = pe.get_string_at_rva( 62 | i.AddressOfData + 2, 63 | pefile.MAX_IMPORT_NAME_LENGTH 64 | ) 65 | if not pefile.is_valid_function_name(fun_name): 66 | continue 67 | else: 68 | text_ranges.append(( 69 | self.image_base+i.AddressOfData, 70 | self.image_base+i.AddressOfData+len(fun_name) 71 | )) 72 | else: 73 | raise Exception 74 | return text_ranges 75 | 76 | def get_ranges(self): 77 | result = [] 78 | for forbidden_area in self.forbidden_area: 79 | zone = self.pe.OPTIONAL_HEADER.DATA_DIRECTORY[pefile.DIRECTORY_ENTRY[forbidden_area]] 80 | if zone.Size and zone.VirtualAddress: 81 | forbidden_start = zone.VirtualAddress + self.image_base 82 | forbidden_end = zone.Size + forbidden_start 83 | result.append((forbidden_start, forbidden_end)) 84 | result.extend(self.get_import_by_names()) 85 | return result 86 | 87 | @staticmethod 88 | def is_in_range(addr, ranges): 89 | for i in ranges: 90 | start = i[0] 91 | end = i[1] 92 | if start < addr and addr < end: 93 | return True 94 | 95 | @staticmethod 96 | def filter_range(addrs, forbidden_range): 97 | return list(filter(lambda k: k not in forbidden_range, forbidden_range)) 98 | 99 | 100 | class MissingRangeFinder(object): 101 | 102 | def __init__(self, ranges, addr_start, addr_end): 103 | self.ranges = ranges 104 | self.addr_start = addr_start 105 | self.addr_end = addr_end 106 | 107 | def find_next_range(self, addr): 108 | # prev = None 109 | for i in self.ranges: 110 | if addr <= i[0]: 111 | self.ranges.remove(i) 112 | return i 113 | # prev = i 114 | 115 | def find_missing_ranges(self): 116 | done = False 117 | addr = self.addr_start 118 | gap_start_addr = None 119 | results = [] 120 | while not done: 121 | next_range = self.find_next_range(addr) 122 | if not next_range: 123 | results.append((addr, self.addr_end)) 124 | done = True 125 | else: 126 | gap_start_addr = addr 127 | gap_end_addr = next_range[0] 128 | if gap_start_addr != gap_end_addr: 129 | results.append((gap_start_addr, gap_end_addr)) 130 | addr = next_range[1] 131 | 132 | return results 133 | 134 | 135 | class NonTextSegment(object): 136 | 137 | def __init__(self, *segs, forbid): 138 | self.segea = segs[0] 139 | self.forbid = forbid 140 | # self.segea = segea 141 | if len(segs) == 1: 142 | self.segend = idc.get_segm_end(self.segea) 143 | else: 144 | self.segend = segs[1] 145 | 146 | def is_entire_code(self): 147 | for head in idautils.Heads(self.segea, self.segend): 148 | flags = ida_bytes.get_flags(head) 149 | if ida_bytes.is_align(flags) or ida_bytes.get_bytes(head, 2) == b"\xcc\xcc": 150 | continue 151 | if not ida_bytes.is_code(flags): 152 | return False 153 | return True 154 | 155 | def seperate_code_data(self): 156 | codes = [] 157 | datas = [] 158 | prev_head = self.segea 159 | prev_status = None 160 | for head in idautils.Heads(self.segea, self.segend): 161 | flags = ida_bytes.get_flags(head) 162 | if ida_bytes.is_align(flags) or ida_bytes.get_byte(head) == 0xcc: 163 | continue 164 | if not prev_status: 165 | if ida_bytes.is_code(flags): 166 | prev_status = 'code' 167 | else: 168 | prev_status = 'data' 169 | if ida_bytes.is_code(flags): 170 | if prev_status == 'data': 171 | # data->code 172 | datas.append((prev_head, head)) 173 | prev_status = 'code' 174 | prev_head = head 175 | else: 176 | # code->code 177 | continue 178 | else: 179 | if prev_status == 'code': 180 | # code->data 181 | codes.append((prev_head, head)) 182 | prev_status = 'data' 183 | prev_head = head 184 | else: 185 | # data->data 186 | continue 187 | if prev_status == 'code': 188 | codes.append((prev_head, self.segend)) 189 | else: 190 | datas.append((prev_head, self.segend)) 191 | return (codes, datas) 192 | 193 | def _parse_before_first_head(self, start, end): 194 | addr = start 195 | result = [] 196 | while True: 197 | if not ForbiddenRangeFinder.is_in_range(addr, self.forbid): 198 | data = int.from_bytes( 199 | idc.get_bytes(addr, 0x4), 200 | byteorder='little' 201 | ) 202 | if is_in_original_range(data): 203 | result.append(addr) 204 | addr += 0x4 205 | if addr >= self.segend: 206 | break 207 | return result 208 | 209 | def parse(self): 210 | result = [] 211 | heads = list(idautils.Heads(self.segea, self.segend)) 212 | if heads[0] != self.segea: 213 | result.extend( 214 | self._parse_before_first_head( 215 | self.segea, heads[0] 216 | ) 217 | ) 218 | for idx, _addr in enumerate(heads): 219 | addr = _addr 220 | if not idx + 1 == len(heads): 221 | # check if len<4 222 | if heads[idx+1] - addr < 4: 223 | # print("Skipping", hex(addr), "due to <4") 224 | continue 225 | segend = heads[idx+1] 226 | else: 227 | segend = self.segend 228 | 229 | while True: 230 | # print("parse", hex(addr)) 231 | if not ForbiddenRangeFinder.is_in_range(addr, self.forbid): 232 | data = int.from_bytes( 233 | idc.get_bytes(addr, 0x4), 234 | byteorder='little' 235 | ) 236 | if is_in_original_range(data): 237 | result.append(addr) 238 | addr += 0x4 239 | if addr >= segend: 240 | break 241 | return result 242 | 243 | 244 | class TextSegment(object): 245 | 246 | def __init__(self, fname, forbid): 247 | self.forbid = forbid 248 | self.fname = fname 249 | self.relocs = [] 250 | self.non_code_zones = [] 251 | pass 252 | 253 | def parse_function(self, func_start, func_end): 254 | for head in idautils.Heads(func_start, func_end): 255 | inst = idautils.DecodeInstruction(head) 256 | if inst: 257 | for i in inst.ops: 258 | if i.type == ida_ua.o_imm: 259 | # mov x, 260 | if is_in_original_range(i.value): 261 | self.relocs.append(head + i.offb) 262 | continue 263 | elif i.type == ida_ua.o_mem: 264 | # mov x, ds: 265 | if is_in_original_range(i.addr): 266 | self.relocs.append(head + i.offb) 267 | continue 268 | elif i.type == ida_ua.o_displ: 269 | # mov x, byte ptr [x+x] 270 | if is_in_original_range(i.addr): 271 | self.relocs.append(head + i.offb) 272 | continue 273 | 274 | def parse(self, segea): 275 | function_ranges = [] 276 | for funcea in idautils.Functions(segea, idc.get_segm_end(segea)): 277 | func = ida_funcs.get_func(funcea) 278 | function_ranges.append((func.start_ea, func.end_ea)) 279 | self.parse_function(func.start_ea, func.end_ea) 280 | finder = MissingRangeFinder( 281 | function_ranges, segea, idc.get_segm_end(segea)) 282 | missing_ranges = finder.find_missing_ranges() 283 | 284 | for i in missing_ranges: 285 | # print('missing range', hex(i[0]), hex(i[1])) 286 | non_text_parser = NonTextSegment(i[0], i[1], forbid=self.forbid) 287 | if non_text_parser.is_entire_code(): 288 | self.parse_function(i[0], i[1]) 289 | else: 290 | codes, datas = non_text_parser.seperate_code_data() 291 | for code in codes: 292 | # print('missing range - code', hex(i[0]), hex(i[1])) 293 | # print('parse code', hex(i[0]), hex(i[1])) 294 | self.parse_function(code[0], code[1]) 295 | for data in datas: 296 | # print('missing range - data', hex(i[0]), hex(i[1])) 297 | # print('parse data', hex(i[0]), hex(i[1])) 298 | _non_text_parser = NonTextSegment(data[0], data[1], forbid=self.forbid) 299 | result = _non_text_parser.parse() 300 | self.relocs += result 301 | # print(len(result)) 302 | # print(len(self.relocs)) 303 | # if 0x40e85695 in self.relocs: 304 | # print('found wtf!') 305 | # print(0x40) 306 | # for i in result: 307 | # print(hex(i)) 308 | # print(len(self.relocs)) 309 | # if seperated: 310 | # code = seperated[0] 311 | # data = seperated[1] 312 | # self.parse_function(code[0], code[1]) 313 | # _non_text_parser = NonTextSegment(data[0], data[1], forbid=self.forbid) 314 | # self.relocs += _non_text_parser.parse() 315 | 316 | return self.relocs 317 | 318 | 319 | def run(): 320 | 321 | ida_auto.auto_wait() 322 | all_result = [] 323 | 324 | fname = ida_nalt.get_root_filename() 325 | forbidden_range_finder = ForbiddenRangeFinder(fname) 326 | forbidden_ranges = forbidden_range_finder.get_ranges() 327 | 328 | for segea in idautils.Segments(): 329 | # print("scanning segment", idc.get_segm_name(segea)) 330 | if idc.get_segm_name(segea) == '.text': 331 | parser = TextSegment(fname, forbidden_ranges) 332 | result = parser.parse(segea) 333 | all_result.extend(result) 334 | # print("text", len(result)) 335 | # for i in result: 336 | # print(hex(i)) 337 | elif idc.get_segm_name(segea) == '.rsrc': 338 | continue 339 | else: 340 | # print("nontextseg", hex(segea)) 341 | parser = NonTextSegment(segea, forbid=forbidden_ranges) 342 | result = parser.parse() 343 | all_result.extend(result) 344 | # for i in result: 345 | # print(hex(i)) 346 | # print("found {} relocs".format(len(all_result))) 347 | with open('.'.join(fname.split('.')[:-1]) + '.relocs.txt', "w") as f: 348 | f.writelines(["%s\n" % hex(i) for i in all_result]) 349 | 350 | ida_pro.qexit(0) 351 | 352 | 353 | if __name__ == '__main__': 354 | run() 355 | --------------------------------------------------------------------------------