An attacker who has corrupted memory in a running program still has to do something with it, and almost always that means reaching a system call — the only way to affect anything outside the process; existing defences guard the ROUTES an attacker might take, checking every control transfer or every memory write, which is expensive enough to be rarely deployed and evadable by finding a route the defence does not model, such as corrupting an array index rather than a pointer; this paper (Virginia Tech and Oregon State) guards the DESTINATION instead, running three checks only when a sensitive system call is attempted, and finds that in three real server programs such calls are never legitimately made through a function pointer at all.
The argument in one glance
-
Problem
Whatever the attack, it has to end at a system call
An attacker who has found a memory corruption bug can rewrite parts of a running program, but that alone achieves nothing outside the process. To start a shell, make injected data executable, gain privileges or open a network connection, they must invoke a SYSTEM CALL — the only interface a program has to the operating system. Modern Linux has over 400 of them, but attackers want a specific handful: the ones controlling processes, memory permissions, privileges and networking. Nearly every serious attack chain, whatever techniques it uses along the way, passes through one of these.
-
What people did
Remove the calls you do not need, and guard every route to the ones you do
Two families of defence exist and both are sensible. Debloating and system-call filtering remove or forbid calls a program never uses, shrinking the attack surface — this is what tools like seccomp do and it is widely deployed. Separately, control-flow integrity checks that every indirect jump lands somewhere the program could legitimately have gone, and data-flow integrity checks that every memory read gets a value some legitimate write produced. Both are principled and effective against large classes of attack.
-
Why it fell short
You cannot remove what the loader needs, and guarding routes is expensive and evadable
Filtering makes a binary decision per program, so a call the program genuinely needs somewhere stays available everywhere — and memory-permission calls are needed by the dynamic loader in essentially every program. Argument restrictions have the same problem: applied across the whole program, they must permit the union of every legitimate use. Meanwhile guarding routes is costly, because it means instrumenting every branch or every memory access, and it is evadable: one published attack reaches a system call by corrupting an array INDEX rather than any pointer, defeating defences built around pointer integrity. And even a hypothetically perfect control-flow defence would not help, since an attacker can leave the control flow alone and corrupt the arguments instead.
-
This paper's idea
Guard the destination, not the routes
This paper (Virginia Tech and Oregon State — Jelesnianski, Ismail, Jang, Williams, Min) checks three things, but only at the moment a sensitive system call is attempted. Was it reached the right WAY, meaning direct call versus function pointer. Did control ARRIVE along a path the program could really have taken, verified by walking the actual stack. And are the ARGUMENTS the values the program itself computed, checked against a shadow copy. Each is a narrowed version of an expensive whole-program defence, applied only at the chokepoint — and since programs cross that chokepoint rarely, the cost is 0.60 to 2.01 percent. A key empirical finding underpins it: in three real server programs, sensitive system calls are never legitimately reached through a function pointer.
Why this problem is worth solving
The reason a system call is the right place to stand is worth making precise.
A program can compute
whatever it likes inside its own memory without affecting anything. To have an
effect on the world — start another program, change which memory is executable,
change its own privileges, open a socket — it must ask the operating system, and
that request is a SYSTEM CALL. Modern Linux offers more than 400, but the
security-relevant set is small: this paper protects twenty, covering arbitrary
code execution (execve, fork, clone, ptrace), memory permissions (mprotect, mmap,
mremap), privilege escalation (chmod, setuid, setgid) and networking (socket,
bind, connect, accept). An attacker who has corrupted memory still holds nothing
useful until one of these runs. That makes it a CHOKEPOINT: whatever route the
attack took — return-oriented programming, a hijacked function pointer, a
corrupted array index — it must arrive here, so a check placed here sees every
attack regardless of how it travelled
The existing defences guard the routes instead, and both of their weaknesses
follow from that choice.
CONTROL-FLOW
INTEGRITY checks that each indirect jump lands on a target the program could
legitimately reach. Its precision depends on static analysis: for each call site
it computes a set of allowed targets, and imprecise analysis makes those sets
large enough to leave attackers room. Making them exact is possible but requires
heavy instrumentation or processor tracing, so the cost is high. DATA-FLOW
INTEGRITY has the same shape, tracking every load and store. Two deeper problems
remain. Evadability: a published attack reaches a system call by corrupting an
ARRAY INDEX so a lookup runs past the end of a function-pointer table — no
pointer is ever corrupted, so defences built around pointer integrity see
nothing. And insufficiency: even a hypothetically perfect control-flow defence
with zero cost would not help, because the attacker can leave control flow alone
and corrupt the ARGUMENTS of a system call the program was going to make
anyway
What people did before, and where it broke
- Debloat the binary. Static analysis or test coverage identifies code the program never runs, and it is removed, eliminating the system calls that code would have made. Genuinely reduces the attack surface.
- But the calls attackers want are the ones nobody can remove. Memory permission calls are used by the dynamic loader to map libraries, so they survive debloating in essentially every program.
- Filter system calls per process. Seccomp lets an administrator allow or deny each call for a process, and even pin an argument to a constant. Widely deployed and cheap.
- But a per-process decision has no notion of context. A call needed anywhere is permitted everywhere, and an argument restriction must permit the union of every legitimate use — so a program that legitimately maps memory read-only in one place and executable in another must permit both, everywhere.
- Enforce control-flow integrity across the program. Check every indirect transfer against a statically computed set of legal targets.
- But precision and cost trade against each other, and it is not enough anyway. Coarse versions leave sets large enough to exploit; exact versions need heavy instrumentation. And an attacker facing perfect control-flow enforcement can simply corrupt the arguments instead.
- Enforce data-flow integrity across the program. Instrument every load and store so a read only ever sees a value a legitimate write produced.
- But instrumenting every memory access is very expensive, and effectiveness depends on the accuracy of the pointer analysis behind it.
- The gap. Every defence was placed along the routes an attacker might travel, which means paying everywhere and still missing routes nobody modelled. Nobody had asked what could be checked at the one point every attack must reach — where the check runs rarely, and where the attacker has run out of alternatives.
How three checks at one point replace three defences everywhere
Do nothing until a sensitive system call is attempted, then ask three questions
-
Guard every branch and every memory write
Whole-program control-flow and data-flow integrity instrument each indirect transfer and each load and store. Principled and effective, but the cost falls on every branch a program takes, and the protection is only as precise as the static analysis behind it.
-
Work out at compile time how each call is legitimately reached new
Analyse the program to record, for each of the twenty sensitive system calls, whether it is called directly, through a function pointer, or never; every caller-and-callee pair on paths that reach it; and which variables its arguments depend on. Emit this as metadata alongside the binary.
-
Track only the values that become arguments new
Instrument writes to those specific variables so a protected shadow copy stays current. This is the only instrumentation added, and it covers system call arguments and the variables they derive from rather than all program data.
-
Trap the twenty calls into a separate monitor process new
Use the kernel filtering mechanism to trap exactly those calls and hand control to a monitor running as its own process, so an attacker who controls the application cannot disable or bypass the checks from inside it.
-
Ask whether it was called the right way, and reached correctly new
Decode the instruction at the trapped address to see whether the call was direct or indirect, and compare against what the program actually does. Then unwind the real stack, checking each caller-and-callee pair against the recorded ones, until reaching the program entry point or an indirect call.
-
Ask whether the arguments are the ones the program computed new
For each argument, compare the register value against the shadow copy, following pointers where an argument is indirect. Any mismatch, at any of the three questions, means the application is killed rather than allowed to proceed.
The idea
Stand where the attacker must arrive, not where they might travel. Every defence in the comparison is placed along a route: control-flow integrity on transfers, data-flow integrity on memory accesses, pointer integrity on pointers. That has two consequences. The cost is paid continuously, because routes are travelled constantly. And the protection is only as complete as the enumeration of routes, which is why one published attack works by corrupting an array INDEX — not a pointer, so nothing guarding pointers reacts, yet the lookup runs past the end of a function-pointer table and lands on a system call. A check at the system call itself is indifferent to how the attacker got there. It also runs astonishingly rarely: the authors found that NGINX makes its sensitive system calls almost entirely during startup and hardly ever while serving requests, so the monitor is barely triggered at steady state. That is the whole reason three expensive-sounding checks cost under one percent. Choosing to act at the point where a problem becomes unavoidable, rather than everywhere it might originate, is the same intervene-at-the-chokepoint reasoning covered elsewhere on this site where a design identifies the moment at which harm becomes possible and does its work only there.
Each check is a known defence with its scope collapsed. The three contexts are not new inventions so much as existing techniques narrowed until they become affordable. Checking how a call was made is system-call filtering, but per call site instead of per process, distinguishing not merely allowed from forbidden but callable-directly from callable-by-pointer from never-callable. Checking how control arrived is control-flow integrity, but evaluated only along paths that reach a system call and only at the moment one is attempted, by walking the real stack rather than instrumenting every branch. Checking the arguments is data-flow integrity, but restricted to system call arguments and the variables they derive from — and checking VALUES against a shadow copy rather than tracking flows. The measurements show why the narrowing matters: instrumentation reaches a maximum of 5,287 points in NGINX, and stacks at system call sites average 5.2 frames deep, so unwinding them is cheap. Taking a technique that is correct but unaffordable at full scope and making it affordable by restricting where it applies is the same shrink-the-domain-not-the-guarantee move covered elsewhere on this site where an analysis becomes tractable by being asked a narrower question.
The three checks are not redundant, and the paper’s table proves it. It would be easy to assume the argument check subsumes the others, or that control-flow checking makes call-type checking unnecessary. The evaluation shows otherwise, and it is the most informative result in the paper. Classic return-oriented attacks slip past the call-type check, because they reach calls the program does use in the way it uses them, and are caught by the other two. One attack against Apache slips past the call-type check because that program does legitimately call an exec function through a pointer, and is caught by the control-flow check. And three of the most sophisticated techniques — including one against Chrome that builds its attack entirely from the program’s own legitimate control flow — slip past both, and are caught only by argument integrity. Each layer earns its place by catching something the others cannot. Demonstrating that the components of a defence are individually necessary, rather than asserting defence in depth, is the same show-your-ablation discipline covered elsewhere on this site where a paper’s own decomposition reveals which parts are actually load bearing.
An attack that never corrupts a pointer
The paper's own NGINX example, and what each check sees
A real code pattern in NGINX, attacked by a technique published to defeat pointer-integrity defences.
Step 1 of 5
A perfectly ordinary dispatch table
NGINX has a function that looks up a handler in an array of structures and calls it, passing three arguments that also come from that array and from the request. This is a generic dispatcher for indexed variables, and there is nothing wrong with it — indirect calls through tables are how most large programs are organised.
the call site reads roughly as calling the handler stored at position index in the array, with the request and two other values from the same entry the attacker is assumed to have a memory corruption bug giving arbitrary read and write, with the usual defences already deployed
Why it works
- Every attack must reach a system call to affect anything outside the process → a check placed there is indifferent to the route taken → so an attack that corrupts an array index rather than a pointer is caught the same as one that does not.
- Sensitive system calls are used almost entirely during startup and rarely while serving requests → the monitor is triggered very seldom at steady state → giving 0.60 percent overhead on NGINX despite doing control-flow and data-integrity work.
- Control flow is verified by unwinding the real stack at the call rather than instrumenting every branch → the cost scales with stack depth at system calls, averaging 5.2 frames → instead of with the number of branches the program takes.
- Only variables that become system call arguments are tracked → instrumentation totals at most 5,287 points → rather than every load and store as whole-program data-flow integrity requires.
- The monitor runs as a separate process reached through kernel-level trapping → an attacker controlling the application cannot disable the checks → so full control of the target does not translate into evading them.
What it costs (trade-offs and scope)
- It protects twenty calls, not all of them. The set is chosen to cover arbitrary execution, memory permissions, privilege escalation and networking, and is extensible, but an attack achieving its goal through a call outside the set is outside the design.
- It assumes several defences are already deployed. The threat model requires data execution prevention, address space layout randomisation and a hardware shadow stack, and trusts the kernel and hardware. Attacks against those are out of scope.
- Compile-time analysis is required, so source is needed. This is a compiler pass plus a runtime monitor, not something applied to an existing binary.
- The response to any violation is to kill the program. Appropriate for a security monitor and consistent with how such defences behave, but it means a false positive is an outage. The authors argue their construction admits neither false positives nor false negatives.
- The evaluation is three server programs. They are well chosen — widely deployed, frequently attacked and system-call heavy — but the low overhead depends on the pattern of using sensitive calls mostly at startup, which may not hold for every workload.
The evaluation
Two questions were asked. Against attacks, the system was tested on seven real vulnerabilities in ffmpeg, php, sudo, nginx, libtiff and python, plus five published advanced techniques designed specifically to defeat modern defences, including one that bypasses pointer integrity and one built entirely from a program’s own legitimate control flow. For cost, NGINX, SQLite and vsftpd were measured with each context enabled in turn, on an eight-core AMD machine with address space layout randomisation and the hardware shadow stack active, averaged over five runs.
Stopping an attacker from weaponising a system call
| Property | Filter calls per process | Guard every control transfer | Check three things at the call |
|---|---|---|---|
| Calls the program legitimately needs | |||
| Attack corrupting an index, not a pointer | |||
| Attack corrupting only arguments | |||
| Where the cost falls | |||
| Measured overhead |
Check yourself
Check your understanding
1. The paper argues that even a hypothetically perfect control-flow defence — exactly one legal target per call site, zero overhead — would still leave system calls exploitable. Predict why.
2. One attack redirects execution to a memory-permission system call in a program that never calls it. Coarse-grained control-flow integrity does not stop this. Predict why not.
3. The design performs control-flow and data-integrity style checking, yet costs 0.60 percent on NGINX. Predict what makes that possible.
4. The results table shows some attacks bypassing the call-type check, and others bypassing both it and the control-flow check. Predict what that pattern establishes.
Sources
- Protect the System Call, Protect (Most of) the World with BASTION (author PDF) — ASPLOS 2023; the full 14-page paper, read in full for this explainer. Published open access under a Creative Commons licence.
- ACM Digital Library (DOI 10.1145/3582016.3582066) — published version.