Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

A Table You Can Locate and Not Read

26 minadvancedModule 1: The Table and the Numbers
26 min · Exceptions, Privilege and Mode Changes

Why this matters

Something happens every time your program asks for a thing it cannot do for itself — read a file, change a page's permissions, ask the clock. Every one of those events passes through a hardware table, and on x86-64 that table has a name, an address, and a size. You can read two of those three from user mode.

That sounds like a curiosity. It is not. It is the entire privilege story in one asymmetry, and the rest of this course is a consequence of it. A process that knows where the gates are, and cannot read one, can still name a vector and get a fault — which is exactly what lets a program ask a question and get an answer, and exactly what stops it choosing the answer. Understanding that difference, precisely, is what separates “the operating system trusts this process” from “the hardware refuses to let this process do that”, and only the second kind survives a compromised kernel.

There is also a trap here that has caught the author of this course, and it is worth stating up front because it is the sort of error that does not announce itself.

The model: a register, a ten-byte slot, and a table

SIDT stores the IDT register into a ten-byte region of memory. That region has a fixed layout, and the order is the base first and the limit second:

offset 0 .... 7 the 64-bit IDT base address offset 8 .... 9 the 16-bit IDT limit, in bytes 10 bytes total. This is the "pseudo-descriptor" that LIDT, LGDT, SIDT and SGDT all use. THE ORDER IS BASE FIRST. See the Reality unit: getting it backwards produces no fault at all.

The limit is a byte count and every entry is 16 bytes, so the number of entries the hardware will look up is limit/16 + 1. On the machine this course was written for:

$ ./privbench | sed -n '/the ten bytes SIDT/,/entries of 16 bytes/p'
   the ten bytes SIDT actually wrote: 00 00 00 00 ff ff ff ff ff ff
   SIDT  base  = 0xffffffff00000000
   SIDT  limit = 65535  ->  4096 entries of 16 bytes

Three things to take from those ten bytes.

First, the base is not an allocation.0xffffffff00000000 is exactly 232 below the top of a 48-bit address space, and it is the address the x86-64 architecture itself uses. A kernel may choose another; this one did not. Notice that the artifact does not assume this — it prints the number it read and then checks whether the number has the expected shape, because a course that hard-codes the answer teaches the answer.

Second, 4096 entries is not a claim that 4096 interrupts exist. It is a bound, and it is a deliberately conservative one: 65535 is the largest limit a 16-bit field can express, so setting it to the maximum means that int 0xNN with any possible byte is a bounds check against the table and not a wild read. A kernel that set the limit to 64 would be faster to reason about and would turn every stray int into memory corruption. 4096 entries is a security decision wearing a number's clothes.

Third, and this is the concept: the register is readable and the table is not.

$ ./privbench | sed -n '/Reading the first 8 bytes/,/cost a fork/p'
   Reading the first 8 bytes of the IDT from ring 3: FAULTED
      the child died of signal 11 (SIGSEGV)
   The register is readable; the TABLE is not.  SIDT tells a process where
   the gates are, and gives it no way to read one.  That asymmetry is the
   whole privilege story in one line, and it cost a fork to establish.

Note the method. The read runs in a forked child, not in the benchmark itself, and the child is reported by the signal it died from. This is not caution for its own sake: an earlier version of this course's artifact read an MSR in the parent process and killed every measurement in the run with it. Any experiment whose expected outcome is a fault belongs in a child process, and the harness should be built that way from the start rather than after it has cost you a run.

The reality: the layout has a trap in it

Almost every prose description of the pseudo-descriptor says “the 16-bit limit and the 64-bit base”, in that order. It is wrong. And — here is what makes it a genuinely dangerous error rather than a merely embarrassing one — reading it the documented way does not fault. It returns numbers.

$ ./privbench | sed -n '/read the other way round/,/not loud/p'
   read the other way round -- limit first, then base:
      base  = 0xffffffffffff0000   limit = 0
      No fault.  No obviously impossible value.  A tool written that way
      reports a limit of 0 and a base in the middle of nowhere, and a
      limit of 0 is INDISTINGUISHABLE from a kernel that installed an
      empty IDT.  This is the worst kind of wrong: it is not loud.

This is worth pausing on, because it generalises past descriptors. A struct declared { uint16_t limit; uint64_t base; } and marked packed is 10 bytes and looks correct, and the compiler will not warn you, and the program will run, and the output will be a plausible base in the middle of nowhere with a limit of zero. The failure mode is silent, it is stable, and it is indistinguishable from a legal state.

The general rule: prefer a reading that fails loudly to one that returns a plausible wrong answer. A parser that validates its input and rejects it can be debugged. A parser that confidently reports a limit of zero will be believed, and the belief will propagate.

What a gate actually is

Each of the 4096 slots is a 16-byte descriptor. In long mode it is:

byte 0.. 1 offset 15:0 of the handler address byte 2.. 3 segment selector byte 4.. 5 offset 31:16, and in the same 16-bit word: bits 4:0 IST index (which stack to switch to) bits 11:8 type (0xE interrupt, 0xF trap) bit 12 P present bits 14:13 DPL descriptor privilege level byte 8..11 offset 63:32 of the handler address byte 12..15 reserved

And here is a second thing that the tables in textbooks get wrong, in a way that is easy to miss because the error is a missing term rather than a wrong one. The rule most courses state for “gates” is

MAX(CPL, RPL) <= DPL

That is the call-gate rule, and it is correct — for call gates. It is not the interrupt-gate rule, because an IDT entry is reached by a vector number and a vector number has no RPL field to carry. Intel's own pseudocode is one-sided:

IF gate.DPL < CPL THEN #GP(vector, 1, 0)

and AMD states the reason explicitly in its privilege-checks section: “unlike call gates, no RPL comparison takes place. This is because the gate descriptor is referenced in the IDT using the interrupt vector number rather than a selector, and no RPL field exists in the interrupt vector number.”

Where does the folklore come from, then? From the manuals themselves, being internally inconsistent: Intel's task-gate pseudocode includes the RPL comparison, and the task gate is also reached by a vector number. So the manuals disagree with each other in the same volume, and the widely-repeated two-sided rule is a synthesis that fits one gate type and not the other. If you are writing a kernel and you use the two-sided rule for IDT gates, you will reject transitions you were supposed to allow, and the symptom will be a system that will not boot for reasons nobody can find.

Worked: three questions with the artifact

Build it and ask it things. The three below are the ones worth asking first, and the third is the one that teaches the most.

Q1. Where does this kernel put its IDT? $ ./privbench | grep -A2 "the ten bytes" -> base 0xffffffff00000000, limit 65535 Do NOT conclude from this that every kernel chooses the same base. A kernel may choose another. Read the number; do not assume it. Q2. How many vectors can the hardware even look up? limit / 16 + 1 = 4096. That is an upper bound the hardware enforces, not an inventory of what exists. On a machine with 12 threads and one APIC there might be forty vectors in use. Q3. Break the field order on purpose. Change struct pseudo_desc from { uint64_t base; uint16_t limit; } to { uint16_t limit; uint64_t base; } rebuild, and rerun the cross-check. Group B fails on three named checks: the entry count is limit/16 + 1 the base is 2^32 below the top of a 48-bit space reading the pseudo-descriptor backwards is shown wrong Note WHICH three and WHY. A harness that just goes red has told you nothing.

Q3 is the one to do. Exercise 3 exists because a check that has never been seen to fail is a check nobody has reason to believe — and the memory course made the same argument about its own verification. Break it, watch the specific checks fail, then put it back.

Apply it

  1. Write the ten bytes out by hand and decode them. From the ten bytes 00 00 00 00 ff ff ff ff ff ff, write the base and the limit, then explain why the base is 00 00 00 00 ff ff ff ff and not ff ff ff ff 00 00 00 00. (Little-endian, offset 0 first — and the "offset 0 first" is the whole lesson.)
  2. Name the four fields you would check before believing any IDT dump. You are looking at a core dump or a hypervisor trace. Which bytes tell you the gate is present, which tell you its type, which tell you its DPL, and where is the handler address assembled from? (P is bit 12 of bytes 4–5; type is bits 11:8 of the same word; DPL is bits 14:13; the offset is three separate fields at bytes 0–1, 8–11, and the top of bytes 4–5.)
  3. Explain why int $0x80 is a memory-safety question and not a style question. Hint: what does the hardware do with a vector for which (vector << 4) + 15 exceeds the limit?
  4. Find the limit on a machine you have.SGDT is the same shape and has the same field order; use it on a 32-bit bootloader or read it under a hypervisor. Then explain why the GDT's limit is a different kind of number from the IDT's.

Connect

Forward, the next concept asks what is in the 4096 slots — and finds that eight of the thirty-two defined numbers mean different things to Intel and AMD, which is a stronger statement about the boundary than anything in this one. The doors concept then shows that two of the three ways into the kernel never consult this table at all, which is why they are faster and why they are also unchecked by hardware. The three-architectures concept is where the DPL-vs-RPL subtlety reappears as a genuinely different design: RISC-V makes the delegation a writable field the firmware owns.

Backwards, the security course's posture concept enumerated the control-register bits as flags a hardening policy can set, and never asked who is allowed to read them. Answer: nobody in ring 3, which is why this course reports SMEP and SMAP as a report rather than a measurement. And the memory course's translation concept measured what happens when an address reaches the page tables with nothing there — this concept is the layer above that, where the address is rejected before it ever gets the chance.

Outward: if you are writing a compiler backend, this is the point where “the program runs” stops being a metaphor. Your code will eventually execute syscall; someone has to have put a gate in that table, and the DPL on that gate is what makes your program's request reach a handler instead of a wall. You do not need to know the answer to build the next course in this chain. You do need to know the question exists.

render_lesson_js(&mut page)