Back to course

The Startup Sequence

15 minadvancedModule 8: Loading & ExecutionContinues from pe-memory-layout
15 min · Module 8: Loading and Execution · Runtime

Why This Matters

You have followed the PE from bytes on disk to a mapped image. The last hop is the one everybody skips: what actually runs first, and what has to happen before your main function sees a single line. The answer explains a whole class of real behavior — why a global C++ object can run code before main, why smashing a stack buffer can terminate a process instead of returning an error, why exception handlers work at all, and where those first crash addresses in a debugger come from.

It also completes the contract. The loader jumps to AddressOfEntryPoint and its job is done. Everything after that is code the file itself shipped — the CRT startup, your entry, the exit path. The PE format drew the runway; the program takes off from it.

A Simple Model

Honest framing first: the PE specification defines the data, not the operating system algorithm, and kernel-to-thread handoff details are out of scope. What follows is a model built only from things you can verify in this file or in Microsoft documentation.

  1. The loader jumps to AddressOfEntryPoint — for program images the spec calls this the starting address. In cli-64.exe that is RVA 0x1D40, VA 0x140001D40.
  2. The CRT entry runs first. The linker default for a console program is mainCRTStartup, which calls main (Microsoft, "/ENTRY"). You never see its name in the symbol table of a stripped binary — you see its bytes.
  3. Security cookie setup. Before anything can overflow, __security_init_cookie reseeds the global cookie from system time, process and thread ids, and a performance counter.
  4. CRT initialization. The startup path initializes the CRT, runs static initializers, then calls main with the standard arguments.
  5. Exceptions are wired in. SetUnhandledExceptionFilter and the unwinder imports (RtlLookupFunctionEntry, RtlVirtualUnwind) show the machinery the CRT installs around your code.
  6. Exit flows back. main returns into the same startup code, which leaves through exit or the other CRT exit paths — TerminateProcess sits in the import table for the hard cases.
Common mistake. Thinking AddressOfEntryPoint points at main. It points at the CRT wrapper. main is called several instructions and several thousand bytes later, after globals are initialized and the cookie is set.

The Actual Detail

The entry itself, first 32 bytes at file offset 0x1140 (RVA 0x1D40):

00001140: 4883 ec28 e8d7 0300 0048 83c4 28e9 72fe
00001150: ffff cccc 4053 4883 ec20 488b d933 c9ff

Disassembled, it is four moves — addresses verifiable with objdump:

140001d40: sub  rsp, 0x28          ; shadow space for the call
140001d44: call 140002120          ; __security_init_cookie
140001d49: add  rsp, 0x28
140001d4d: jmp  140001bc4          ; tail-jump into CRT startup
140001d52: int3                     ; padding
140001d54: rex push rbx             ; next function begins here

The cookie routine at 0x140002120, verified instruction by instruction (listed in Intel-style operand order, abridged for width):

140002120: mov  [rsp+0x18], rbx     ; home rbx
140002125: push rbp
140002126: mov  rbp, rsp
140002129: sub  rsp, 0x30
14000212d: mov  rax, [140005008]    ; current cookie slot in .data
140002134: mov  rbx, 0x2b992ddfa232 ; link-time default
14000213e: cmp  rax, rbx
140002141: jne  1400021b7           ; not the sentinel, skip seeding
14000214c: call GetSystemTimeAsFileTime ; seeds [rbp+0x10]
14000215a: call GetCurrentThreadId      ; xor into accumulator
140002166: call GetCurrentProcessId     ; xor into accumulator
140002176: call QueryPerformanceCounter ; fills [rbp+0x18]
140002183: shl  rax, 0x20
140002187: xor  rax, ...            ; mix counter, time, ids, pointer
140002192: mov  rcx, 0xffffffffffff
14000219c: and  rax, rcx            ; keep 48 bits
14000219f: mov  rcx, 0x2b992ddfa233 ; sentinel plus one
1400021ac: cmove rax, rcx           ; never leave the default in place
1400021b0: mov  [140005008], rax    ; store the fresh cookie
1400021bc: not  rax
1400021bf: mov  [140005000], rax    ; complement slot
1400021cb: ret

Three details worth pinning down. The indirect calls go through the IAT slots we mapped: 0x140003088, 0x140003090, 0x140003098, 0x1400030A0 — the same four imports from the table below. The cmove swaps in sentinel-plus-one if the mix would reproduce the default, so the stored value can never collide with the link-time constant. And a second slot at 0x140005000 receives the bitwise complement — the CRT global __security_cookie_complement, kept beside the cookie so runtime checks can compare against the inverted value.

The slot it seeds is the eight bytes at file offset 0x3008 in .data:

00003008: 32a2 df2d 992b 0000

That is 0x00002B992DDFA232 — the exact default the code compares against, confirming this file still carries the link-time sentinel that the first run replaces.

The tail-jump target 0x140001BC4 opens like classic CRT startup: it saves rbx, rsi, and rdi, passes 1 in the first argument register, calls 0x140001F0C, and branches on the boolean the callee returns — the shape of one-time initialization, though the exact CRT source is outside the PE contract. The documented shape is the same: initialize the CRT, run static initializers, call main, then tear down.

A Real Example

Put the imports next to the sequence and the story closes:

ImportIAT slotUsed for
GetSystemTimeAsFileTime, GetCurrentThreadId, GetCurrentProcessId, QueryPerformanceCounter0x3088, 0x3090, 0x3098, 0x30A0Seeding the security cookie in __security_init_cookie
RtlCaptureContext, RtlLookupFunctionEntry, RtlVirtualUnwind0x30B0, 0x3040, 0x3048Walking .pdata frames for exceptions and debugging
SetUnhandledExceptionFilter, UnhandledExceptionFilter0x3058, 0x3050Available to the startup path for a last-resort crash handler
exit, _exit, _cexit, _c_exit0x3180 and neighborsReturning from main without returning into the loader
TerminateProcess0x3068The hard stop when orderly shutdown is impossible

Every row is a byte you can point at: the cookie seeds come from the entry walk, the unwinder imports match the .pdata section from the previous lesson, and the exit family sits in UCRT waiting for main to come back. The sequence is not folklore — it is the import table telling you what the startup code is allowed to do.

Try It

objdump puts the entry address on the first line of the file report, and -d starts disassembly there (raw output, operands in AT&T order):

$ objdump -d cli-64.exe --start-address=0x140001d40
140001d40: 48 83 ec 28             sub    $0x28,%rsp
140001d44: e8 d7 03 00 00          call   0x140002120
140001d49: 48 83 c4 28             add    $0x28,%rsp
140001d4d: e9 72 fe ff ff          jmp    0x140001bc4

On Windows the same view comes from dumpbin /disasm. What to look for: a call or jump in the first five instructions means you are in the CRT wrapper, not in main — search the import table or step until you see argument setup for the program itself.

Check Your Understanding

Without looking back: a PE with AddressOfEntryPoint at RVA 0x1D40 loads and runs. Is 0x1D40 the address of main?

Apply It

The cookie slot in .data reads 0x00002B992DDFA232 and the entry code compares against that same constant. What does it conclude on first run?

The pattern: the link-time constant is a known value precisely so the loader-side code can tell untouched files from already-running processes.

Connect

That closes the arc: bytes, headers, directories, resources, TLS, exceptions, loading, memory, and now the first instructions that ever execute. Every claim above was read out of cli-64.exe or quoted from the spec and Microsoft documentation.

Back to the course index to review the full sequence, or revisit the loader lesson — it is the hinge the whole module turns on.