|
| 1 | +"""Scoped Windows owned-child minidump prototype; not a process discovery tool.""" |
| 2 | +import ctypes as C |
| 3 | +from ctypes import wintypes as W |
| 4 | +import hashlib |
| 5 | +from pathlib import Path |
| 6 | +import struct |
| 7 | +import time |
| 8 | + |
| 9 | +MAX_BYTES = 32 * 1024 * 1024 |
| 10 | + |
| 11 | + |
| 12 | +def validate_dump(path, expected_pid, expected_created, expected_image): |
| 13 | + """Validate payload, not the writer's exit status; return bounded metadata.""" |
| 14 | + path = Path(path) |
| 15 | + size = path.stat().st_size |
| 16 | + if not 32 <= size <= MAX_BYTES: |
| 17 | + raise ValueError('dump size outside bound') |
| 18 | + data = path.read_bytes() |
| 19 | + def bounds(offset, length): |
| 20 | + if offset < 0 or length < 0 or offset > size or length > size-offset: |
| 21 | + raise ValueError('dump RVA outside file') |
| 22 | + def u32(offset): |
| 23 | + bounds(offset, 4) |
| 24 | + return struct.unpack_from('<I', data, offset)[0] |
| 25 | + def location(offset, required=False): |
| 26 | + length, rva = struct.unpack_from('<II', data, offset) |
| 27 | + bounds(rva, length) |
| 28 | + if required and (not length or not rva): |
| 29 | + raise ValueError('required empty location') |
| 30 | + return length, rva |
| 31 | + signature, version, count, directory, _, timestamp, flags = struct.unpack_from('<4sIIIIIQ', data) |
| 32 | + if signature != b'MDMP' or version & 0xffff != 42899 or not 1 <= count <= 1024: |
| 33 | + raise ValueError('invalid dump header') |
| 34 | + if flags & 2: |
| 35 | + raise ValueError('full memory dump prohibited') |
| 36 | + if directory < 32: |
| 37 | + raise ValueError('invalid directory RVA') |
| 38 | + bounds(directory, count * 12) |
| 39 | + streams = {} |
| 40 | + for index in range(count): |
| 41 | + kind, length, rva = struct.unpack_from('<III', data, directory + index*12) |
| 42 | + bounds(rva, length) |
| 43 | + if kind and kind in streams: |
| 44 | + raise ValueError('duplicate stream') |
| 45 | + if kind: |
| 46 | + streams[kind] = (length, rva) |
| 47 | + def required_stream(kind): |
| 48 | + length, rva = streams[kind] |
| 49 | + if length < 4 or rva < 32: |
| 50 | + raise ValueError('required stream empty') |
| 51 | + return length, rva |
| 52 | + length, rva = required_stream(3) |
| 53 | + threads = u32(rva) |
| 54 | + if not 1 <= threads <= 4096 or 4 + threads * 48 > length: |
| 55 | + raise ValueError('invalid thread list') |
| 56 | + thread_info = [] |
| 57 | + for index in range(threads): |
| 58 | + row = rva + 4 + index*48 |
| 59 | + tid = u32(row) |
| 60 | + stack_bytes, stack_rva = location(row + 32, True) |
| 61 | + context_bytes, context_rva = location(row + 40, True) |
| 62 | + if not tid: |
| 63 | + raise ValueError('empty thread id') |
| 64 | + thread_info.append(dict(id=tid, stack_bytes=stack_bytes, stack_rva=stack_rva, |
| 65 | + context_bytes=context_bytes, context_rva=context_rva)) |
| 66 | + if len({item['id'] for item in thread_info}) != threads: |
| 67 | + raise ValueError('duplicate thread id') |
| 68 | + length, rva = required_stream(4) |
| 69 | + modules = u32(rva) |
| 70 | + if not 1 <= modules <= 4096 or 4 + modules * 108 > length: |
| 71 | + raise ValueError('invalid module list') |
| 72 | + module_info = [] |
| 73 | + for index in range(modules): |
| 74 | + row = rva + 4 + index*108 |
| 75 | + base, image_size, checksum, image_time, name_rva = struct.unpack_from('<QIIII', data, row) |
| 76 | + name_bytes = u32(name_rva) |
| 77 | + if not base or not image_size or not 0 < name_bytes <= 32768 or name_bytes % 2: |
| 78 | + raise ValueError('invalid module identity') |
| 79 | + bounds(name_rva+4, name_bytes) |
| 80 | + name = data[name_rva+4:name_rva+4+name_bytes].decode('utf-16-le') |
| 81 | + cv_length, cv_rva = location(row+76) |
| 82 | + location(row+84) |
| 83 | + info = dict(path=name, base=base, image_size=image_size, checksum=checksum, timestamp=image_time) |
| 84 | + if cv_length: |
| 85 | + cv = data[cv_rva:cv_rva+cv_length] |
| 86 | + info.update(codeview_bytes=cv_length, codeview_sha256=hashlib.sha256(cv).hexdigest()) |
| 87 | + if cv[:4] == b'RSDS' and cv_length >= 24: |
| 88 | + info.update(pdb_guid=cv[4:20].hex(), pdb_age=u32(cv_rva+20), |
| 89 | + pdb_path=cv[24:].split(b'\0',1)[0].decode('utf-8','replace')) |
| 90 | + module_info.append(info) |
| 91 | + if expected_image.casefold() not in {item['path'].casefold() for item in module_info}: |
| 92 | + raise ValueError('owned executable missing from module list') |
| 93 | + misc_length, misc_rva = required_stream(15) |
| 94 | + if misc_length < 24 or u32(misc_rva) < 24: |
| 95 | + raise ValueError('missing process identity') |
| 96 | + misc_flags = u32(misc_rva+4) |
| 97 | + dumped_pid, dumped_created = u32(misc_rva+8), u32(misc_rva+12) |
| 98 | + if misc_flags & 3 != 3 or dumped_pid != expected_pid or dumped_created != expected_created: |
| 99 | + raise ValueError('dumped process identity mismatch') |
| 100 | + return dict(valid=True, signature='MDMP', size=size, sha256=hashlib.sha256(data).hexdigest(), |
| 101 | + version=version, flags=flags, dump_timestamp=timestamp, |
| 102 | + stream_types=sorted(streams), directory_rva=directory, stream_count=count, |
| 103 | + process_id=dumped_pid, process_created_unix= dumped_created, |
| 104 | + thread_count=threads, threads=thread_info, module_count=modules, modules=module_info) |
| 105 | + |
| 106 | + |
| 107 | +def windows_api(): |
| 108 | + kernel = C.WinDLL('kernel32.dll', use_last_error=True, winmode=0x800) |
| 109 | + def bind(name, result, args): |
| 110 | + fn = getattr(kernel, name) |
| 111 | + fn.restype, fn.argtypes = result, args |
| 112 | + return fn |
| 113 | + bind('GetProcessId', W.DWORD, [W.HANDLE]) |
| 114 | + bind('GetProcessTimes', W.BOOL, [W.HANDLE] + [C.POINTER(W.FILETIME)]*4) |
| 115 | + bind('QueryFullProcessImageNameW', W.BOOL, [W.HANDLE, W.DWORD, W.LPWSTR, C.POINTER(W.DWORD)]) |
| 116 | + bind('WaitForSingleObject', W.DWORD, [W.HANDLE,W.DWORD]) |
| 117 | + bind('TerminateProcess', W.BOOL, [W.HANDLE,W.UINT]) |
| 118 | + bind('GetSystemDirectoryW', W.UINT, [W.LPWSTR,W.UINT]) |
| 119 | + bind('GetModuleFileNameW', W.DWORD, [W.HMODULE,W.LPWSTR,W.DWORD]) |
| 120 | + bind('CreateFileW', W.HANDLE, [W.LPCWSTR,W.DWORD,W.DWORD,C.c_void_p,W.DWORD,W.DWORD,W.HANDLE]) |
| 121 | + bind('CloseHandle', W.BOOL, [W.HANDLE]) |
| 122 | + bind('SetFilePointerEx', W.BOOL, [W.HANDLE,C.c_longlong,C.POINTER(C.c_longlong),W.DWORD]) |
| 123 | + bind('WriteFile', W.BOOL, [W.HANDLE,C.c_void_p,W.DWORD,C.POINTER(W.DWORD),C.c_void_p]) |
| 124 | + return kernel |
| 125 | + |
| 126 | + |
| 127 | +def process_identity(kernel, handle, expected_pid=None): |
| 128 | + pid = kernel.GetProcessId(handle) |
| 129 | + values = [W.FILETIME() for _ in range(4)] |
| 130 | + if not pid or not kernel.GetProcessTimes(handle, *(C.byref(v) for v in values)): |
| 131 | + raise C.WinError(C.get_last_error()) |
| 132 | + created = values[0].dwLowDateTime | values[0].dwHighDateTime << 32 |
| 133 | + image = C.create_unicode_buffer(32768) |
| 134 | + length = W.DWORD(len(image)) |
| 135 | + if not kernel.QueryFullProcessImageNameW(handle, 0, image, C.byref(length)): |
| 136 | + raise C.WinError(C.get_last_error()) |
| 137 | + if expected_pid is not None and pid != expected_pid: |
| 138 | + raise ValueError('handle PID mismatch') |
| 139 | + return dict(pid=pid, creation_filetime=created, |
| 140 | + created_unix=created//10_000_000-11_644_473_600, image=image.value) |
| 141 | + |
| 142 | + |
| 143 | +def capture_minidump(child, expected_identity, path, deadline, kernel): |
| 144 | + """Use this Popen's retained handle only. Caller owns time/job retirement.""" |
| 145 | + handle = W.HANDLE(int(child._handle)) |
| 146 | + if process_identity(kernel, handle, child.pid) != expected_identity: |
| 147 | + raise ValueError('owned process identity changed') |
| 148 | + if kernel.WaitForSingleObject(handle, 0) != 258: |
| 149 | + raise ValueError('owned child already exited') |
| 150 | + system = C.create_unicode_buffer(32768) |
| 151 | + if not kernel.GetSystemDirectoryW(system,len(system)): |
| 152 | + raise C.WinError(C.get_last_error()) |
| 153 | + dllpath = str(Path(system.value) / 'dbghelp.dll') |
| 154 | + dbghelp = C.WinDLL(dllpath, use_last_error=True, winmode=0x800) |
| 155 | + loaded = C.create_unicode_buffer(32768) |
| 156 | + if not kernel.GetModuleFileNameW(dbghelp._handle, loaded, len(loaded)): |
| 157 | + raise C.WinError(C.get_last_error()) |
| 158 | + if loaded.value.casefold() != dllpath.casefold(): |
| 159 | + raise ValueError('debug DLL path mismatch') |
| 160 | + class IO(C.Structure): |
| 161 | + _pack_ = 4 |
| 162 | + _fields_ = [('handle',W.HANDLE),('offset',C.c_uint64),('buffer',C.c_void_p),('length',W.DWORD)] |
| 163 | + class InputPrefix(C.Structure): |
| 164 | + _pack_ = 4 |
| 165 | + _fields_ = [('pid',W.DWORD),('process',W.HANDLE),('kind',W.DWORD),('io',IO)] |
| 166 | + # Only the documented prefix/union members actually accessed are modeled. |
| 167 | + assert C.sizeof(C.c_void_p) == 8 and InputPrefix.kind.offset == 12 and InputPrefix.io.offset == 16 |
| 168 | + assert IO.offset.offset == 8 and IO.buffer.offset == 16 and IO.length.offset == 24 |
| 169 | + CALLBACK = C.WINFUNCTYPE(W.BOOL,C.c_void_p,C.POINTER(InputPrefix),C.c_void_p) |
| 170 | + class CallbackInfo(C.Structure): |
| 171 | + _pack_ = 4 |
| 172 | + _fields_ = [('callback',CALLBACK),('parameter',C.c_void_p)] |
| 173 | + # No native direct-write handle is supplied: unsupported callback I/O cannot |
| 174 | + # silently fall back to an uncapped disk writer. |
| 175 | + output = kernel.CreateFileW(str(path),0x40000000,0,None,1,0x80,None) |
| 176 | + if output == W.HANDLE(-1).value: |
| 177 | + raise C.WinError(C.get_last_error()) |
| 178 | + state = dict(io_started=False, io_finished=False, writes=0, max_end=0, rejected=False) |
| 179 | + @CALLBACK |
| 180 | + def callback(_, pointer, out): |
| 181 | + try: |
| 182 | + request = pointer.contents |
| 183 | + status = C.cast(out,C.POINTER(C.c_int32)) |
| 184 | + if request.kind == 11: |
| 185 | + state['io_started'] = True |
| 186 | + status[0] = 1 # S_FALSE requests all I/O through this callback. |
| 187 | + return True |
| 188 | + if request.kind == 6: |
| 189 | + cancel = C.cast(out,C.POINTER(W.BOOL)) |
| 190 | + cancel[0], cancel[1] = True, time.monotonic() >= deadline |
| 191 | + return True |
| 192 | + if request.kind == 12: |
| 193 | + end = request.io.offset + request.io.length |
| 194 | + if (not state['io_started'] or end > MAX_BYTES or time.monotonic() >= deadline): |
| 195 | + state['rejected'] = True |
| 196 | + status[0] = -2147024784 # HRESULT_FROM_WIN32(ERROR_DISK_FULL) |
| 197 | + return False |
| 198 | + written = W.DWORD() |
| 199 | + if not kernel.SetFilePointerEx(output,request.io.offset,None,0): |
| 200 | + return False |
| 201 | + if not kernel.WriteFile(output,request.io.buffer,request.io.length,C.byref(written),None): |
| 202 | + return False |
| 203 | + if written.value != request.io.length: |
| 204 | + return False |
| 205 | + state['writes'] += 1 |
| 206 | + state['max_end'] = max(state['max_end'],end) |
| 207 | + status[0] = 0 |
| 208 | + return True |
| 209 | + if request.kind == 13: |
| 210 | + state['io_finished'] = True |
| 211 | + status[0] = 0 |
| 212 | + return True |
| 213 | + if request.kind in (5,7,9,14): |
| 214 | + return False # no added memory/kernel dump/ignored read errors |
| 215 | + return True # preserve initialized default thread/module flags |
| 216 | + except BaseException as exc: |
| 217 | + state['callback_error'] = type(exc).__name__ |
| 218 | + return False |
| 219 | + info = CallbackInfo(callback,None) |
| 220 | + writer = dbghelp.MiniDumpWriteDump |
| 221 | + writer.argtypes = [W.HANDLE,W.DWORD,W.HANDLE,W.DWORD,C.c_void_p,C.c_void_p,C.POINTER(CallbackInfo)] |
| 222 | + writer.restype = W.BOOL |
| 223 | + try: |
| 224 | + C.set_last_error(0) |
| 225 | + result = writer(handle, child.pid, None, 0x1000, None, None, C.byref(info)) |
| 226 | + error = C.get_last_error() # immediate, before clocks/logs/other APIs |
| 227 | + finally: |
| 228 | + kernel.CloseHandle(output) |
| 229 | + receipt = dict(api='MiniDumpWriteDump', api_result=bool(result), get_last_error=error, |
| 230 | + dump_type=0x1000, full_memory=False, dll_path=loaded.value, |
| 231 | + dll_sha256=hashlib.sha256(Path(loaded.value).read_bytes()).hexdigest(), |
| 232 | + callback=state, max_bytes=MAX_BYTES, owned_identity=expected_identity) |
| 233 | + if result and state['io_started'] and state['io_finished'] and not state['rejected']: |
| 234 | + try: |
| 235 | + receipt['payload'] = validate_dump(path,child.pid,expected_identity['created_unix'],expected_identity['image']) |
| 236 | + receipt['status'] = 'CAPTURED' |
| 237 | + except (ValueError, KeyError, OSError, UnicodeError, struct.error) as exc: |
| 238 | + receipt['status'] = 'UNAVAILABLE' |
| 239 | + receipt['validation_error'] = str(exc) |
| 240 | + else: |
| 241 | + receipt['status'] = 'UNAVAILABLE' |
| 242 | + return receipt |
0 commit comments