Keyboard Shortcuts

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

Descriptor Tables and the Privilege Checks

28 minadvancedModule 1: The Machine's Privileged Side, as an InventoryContinues from x86-exceptions
28 min · The x86-64 Machine

Why this matters

The privilege boundary on x86-64 is not a bit. It is a comparison against a number stored in a table the CPU reads on every mode change, and the number is a field inside an eight-byte descriptor that also says whether the segment is code or data, readable or writable, present or not, and 32-bit or 64-bit. Five decisions in eight bytes, and the first of them is the one people call “privilege”.

What makes this concept worth a full page rather than a paragraph is a measurement that contradicts the sentence almost everybody uses to describe it. The sentence is the descriptor tables are privileged. The measurement is that SIDT and SGDTboth return from ring 3, and that reading one byte of either table does not, and that the failure is si_code 1 with an si_addr equal to the base the instruction just printed.

The model: sixteen bits, three fields, and four numbers

A segment selector is not an index. It is three fields with different jobs, and the two-bit field at the bottom is the one that is checked:

$ ./sysdump | sed -n '/1B5\./,/CALL-GATE rule/p'
  SEL | 15:3 | index  | which descriptor in the GDT or LDT
  SEL | 2    | TI     | 0 = GDT, 1 = the LDT
  SEL | 1:0  | RPL    | requested privilege level, 0 to 3
  SEL | 0xffff | a NULL selector: index 8191, TI 1, RPL 3

  The four privilege numbers and the two comparisons:
    CPL   the current privilege level, from CS.RPL in long mode
    RPL   requested, from the low two bits of the selector loaded
    DPL   descriptor privilege level, bits 14:13 of the descriptor
    for a DATA or CODE segment:  max(CPL, RPL) <= DPL   or  #GP
    for a CALL GATE:             max(CPL, RPL) <= DPL   or  #GP(tss)
    for an INTERRUPT gate:       CPL <= DPL             or  #GP(vector)
                

Three rules, not one, and the difference between the second and the third is the whole of a common mistake. The first two compare the larger of the current and requested levels against the descriptor's. The third does not, and cannot: an interrupt gate is indexed by a vector number, and a vector number has no RPL field. Every textbook that writes MAX(CPL,RPL) ≤ DPL as “the rule for gates” is quoting the call-gate rule for something that is not a call gate, and a kernel that implements the third check with the first will refuse its own timer.

THE FOUR NUMBERS, and only one of them is a privilege level in the ordinary sense. CPL current privilege level. In long mode it is CS.RPL and there is no descriptor behind it, so there is nothing to CHECK it against. RPL requested, two bits at the bottom of a selector. "I would like." DPL descriptor privilege level, bits 14:13 of the descriptor. "I am." IOPL the I/O privilege level, in EFLAGS, and the thing that gates IN, OUT and STR. It is not a ring number and no descriptor holds it. The ring diagram everybody draws has four rings. The machine has three numbers it compares and one it does not.

And the descriptor itself. A 64-bit system segment descriptor is eight bytes, and the fields that matter for this page are two: the type byte, whose low three bits are the access type and whose high bits include P (present) and DPL, and the flags byte, whose L bit is what makes a code segment 64-bit. Long mode adds one rule that has no analogue in 32-bit protected mode: a data selector's base and limit are ignored entirely. That is why %ds being zero is harmless in a 64-bit program, and it is the fact that surprises people who arrive from 32-bit code.

The reality: both pseudo-descriptors return, and neither table can be read

Two instructions, two ten-byte structures, and a field order that is the opposite of what the prose implies:

$ ./sysdump | sed -n '/the ten bytes SIDT/,/UNFALSIFIABLE/p'
  the ten bytes SIDT actually wrote, in memory order:
    00 00 00 00 ff ff ff ff ff ff
  SIDT | base 0xffffffff00000000 | limit 65535 | entries 4096 of 16 bytes
  SGDT | base 0xfffffffe00000000 | limit 65535 | entries 8192 of 8 bytes

  THE FIELD ORDER, and it is the whole of this sub-section:
    offset 0..7  BASE   first
    offset 8..9  LIMIT  second
  Read the documented-in-prose 'limit first, then base' way and:
    base 0xffffffffffff0000  limit 0
  NO FAULT, and no obviously impossible value either.  A limit of
  0 is INDISTINGUISHABLE from a kernel that installed an empty
  IDT, so the wrong reading is not merely wrong, it is
  UNFALSIFIABLE.  A tool written that way reports a plausible
  base in the middle of nowhere and never tells you.
                

The ten bytes are printed before they are interpreted, and reading them is how you check the interpretation: 00 00 00 00 ff ff ff ff is a base of zero and ff ff is a limit of 65535, and the base-first order is the only reading under which the two halves are individually plausible. A limit of 65535 is the largest a 16-bit field can hold, which is the architectural statement; where the base points is a kernel choice, and the two numbers above differ by exactly 232 bytes, which is a decision this kernel made and not a rule.

Then the asymmetry

$ ./sysdump | sed -n '/6A\./,/PAGE-TABLE fact/p'
  ABSENT | what | si_code | si_addr | si_addr equals
  ABSENT | read one byte of the IDT | 1 | 0xffffffff00000000 | the IDT base SIDT printed
  ABSENT | read one byte of the GDT | 1 | 0xfffffffe00000000 | the GDT base SGDT printed
  ABSENT | both are si_code 1, SEGV_MAPERR, which is the shape of
  ABSENT | a MAPPING failure and not the shape of a #GP.  So the
  ABSENT | register is readable, the address is readable, and the
  ABSENT | table is not, and the reason is that no user mapping
  ABSENT | covers it.  That is a PAGE-TABLE fact, not a privilege
  ABSENT | fact, and it is a better story than 'the IDT is
  ABSENT | privileged' because it predicts the si_addr.
                

This is the sharpest thing in the concept, and it is a correction rather than a finding. The obvious sentence is “a ring-3 process cannot read the IDT because the IDT is privileged.” The measurement says something more specific and more useful: the register is readable, the address is readable, and the table is not — and the failure carries an si_addr that is exactly the base SIDT printed. That is a mapping failure, not a general-protection one, and it distinguishes three stories that all sound alike:

And note the two different si_addr values: 0xffffffff00000000 for the IDT and 0xfffffffe00000000 for the GDT. The harness checks each against the base the corresponding instruction printed, so the claim is a comparison and not a coincidence.

The other half of the group

All five of the “store the descriptor table registers” instructions are in one bucket, and the bucket is not the one most answers give:

$ ./sysdump | grep -E '^  EDGE \| (lidt|lgdt|ltr|lldt|sidt|sgdt|sldt|str|smsw) '
  EDGE | lidt                           | descriptor tables | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
  EDGE | lgdt                           | descriptor tables | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
  EDGE | ltr                            | descriptor tables | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
  EDGE | lldt                           | descriptor tables | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
  EDGE | sidt                           | descriptor tables | RETURNED | sig 0  | si_code 0   | si_addr 0x000000000000
  EDGE | sgdt                           | descriptor tables | RETURNED | sig 0  | si_code 0   | si_addr 0x000000000000
  EDGE | sldt                           | descriptor tables | RETURNED | sig 0  | si_code 0   | si_addr 0x000000000000
  EDGE | str                            | descriptor tables | RETURNED | sig 0  | si_code 0   | si_addr 0x000000000000
  EDGE | smsw                           | descriptor tables | RETURNED | sig 0  | si_code 0   | si_addr 0x000000000000
                

Four writes fault and five reads return, and the split is not arbitrary. The writes replace a table the CPU will consult on the next mode change, so they are gated. The reads report where a table is, and that is a diagnostic the architecture deliberately provides — except that CR4.UMIP can close the door. And whatever this kernel has UMIP set to, it is not blocking them, which is a measurement of the effect and not a reading of the bit.STR is the one everybody gets wrong: the check for writing the task register is a CPL-versus-IOPL comparison, and the check for reading it is nothing at all.

And the privilege check itself, made to fail on purpose by loading a selector whose descriptor is not there:

$ ./sysdump | grep -E '^  EDGE \| mov 0x1234'
  EDGE | mov 0x1234,%ax; mov %ax,%ds    | segments          | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
  EDGE | mov 0x1234,%ax; mov %ax,%ss    | segments          | FAULTED | sig 11 | si_code 128 | si_addr 0x000000000000
                

0x1234 is index 0x246 of the GDT, which is past the end of an 8192-entry table, and the CPU raises a #GP with a zero error code — hence si_code 128 and no address. The same instruction with %ds instead of %ss also works, which is the answer to “do data selectors get checked differently from stack selectors”: in long mode, no, and the reason is that both selectors' privilege checks are the same two-bit comparison against a field that was never consulted for the base.

Worked: the two things a segment register still does in 64-bit mode

Read the six selectors this process is actually running with, decoded by the three fields:

$ ./sysdump | grep -E '^  SEG \|' | head -3
  SEG | cs 0x0033 ss 0x002b ds 0x0000 es 0x0000 fs 0x0000 gs 0x0000
  SEG | decoded as index<<3 | TI<<2 | RPL:
  SEG | cs | selector 0x0033 | index 6 TI 0 RPL 3 | CPL IS THE RPL OF CS IN LONG MODE
  SEG | ss | selector 0x002b | index 5 TI 0 RPL 3 |
  SEG | ds | selector 0x0000 | index 0 TI 0 RPL 0 |
                

So in long mode a segment register has exactly two jobs left, and both of them are on this page: the two low bits of CS are the current privilege level, and the two low bits of a data selector are checked against a DPL that nothing else consults. Everything else about segments — bases, limits, expand-down, the 32/64-bit flag for data — is either ignored or moved into a model-specific register. Segmentation survives on x86-64 as a privilege mechanism and nothing else, which is a strange sentence to write about a feature that occupied a chapter in every architecture text for fifteen years.

Apply it

  1. Reproduce both readings, in that order.cd courses/x86sys/assets/samples && ./build_samples.sh, then read section 2B. (Expect the ten raw bytes, the correct base-first reading, and the wrong one with a limit of 0 and no fault. If your kernel's IDT base is not 0xffffffff00000000 that is fine and it is the point: the placement is a kernel choice, and the artifact prints what it read rather than asserting a number.)
  2. Check the adjacency claim instead of the placement. The first draft of this section said the IDT begins exactly where the GDT ends, and computed base + limit + 1 and read the answer off a hex string. (Expect it to be wrong: each table is exactly 216 bytes and the two bases are 232 bytes apart, so the gap is 232 minus 216 and not zero. The retraction is printed in the artifact with both numbers beside it, because a retraction without the numbers is a rumour.)
  3. Write the interrupt-gate check and watch it reject your own timer. Implement the DPL test for gates as max(CPL, RPL) ≤ DPL and see what happens when a ring-3 process takes a timer interrupt. (Expect it to work, because RPL is 0 in a vector, and then expect the same code to be wrong the moment your gate has a nonzero RPL field set for some unrelated reason. The bug is real and it is silent, which is the worst combination.)
  4. Find your own kernel's segment layout from a running process. Read CS and SS and decode the indices as this page does, then read the GDT base with SGDT and try to read descriptor 5 and 6 yourself. (Expect the read to die with si_code 1 and an address equal to the GDT base — exactly the shape of the IDT result, and for the same reason. If it does not, your kernel maps the GDT into user space on purpose, which some hardened kernels do, and the asymmetry this course measured is not universal.)
  5. Count the segment instructions your backend emits. In a compiler that targets long mode, segment overrides and %fs bases are almost the only places segmentation appears. (Expect the answer to be two or three instructions in the entire program, and expect the reason to be that the ABI you have to implement is a register convention. That is the same shape as the argument for the fourth argument being R10, and it is why the machine's segment machinery is now a compatibility feature rather than a mechanism.)

Connect

Backwards, the privilege course's gate-table concept is where this page's central measurement was first taken — SIDT returns and the table does not — and the difference is that this course also reads the GDT, decodes the selector, and shows that the reason for the asymmetry is a mapping rather than a privilege. Its ring-diagram concept is the why behind the max(CPL,RPL) ≤ DPL comparison; this page is the reference for which comparison applies to which gate.

Forwards. The fourth concept is the five control registers, and the connection is the row that failed: CR4.UMIP is the bit that decides whether the five reads in this concept's table are legal at all, and reading it is a fault. This concept measured the consequence; the next one names the bit and says it cannot be read. That pairing is the shape of the whole course and it recurs in every remaining concept.

Outward. The IA32_STAR MSR holds the two selectors a syscall loads on the way in and the two it restores on the way out, which is the one place where this page's material is still load-bearing in long mode: syscall is defined in terms of the very segment registers whose descriptors stopped existing. The ISA course covers the mode-switch machinery; the eighth concept here is where the MSRs themselves are shown to be unreadable.

render_lesson_js(&mut page)