This repository contains the artifacts for the paper "Eviction Notice: Reviving and Advancing Page Cache Attacks", accepted at NDSS '26. The paper can be found here: https://snee.la/pdf/pubs/eviction-notice.pdf.
The artifact is at: https://zenodo.org/records/17915256
Page cache attacks (Gruss et al., CCS 2019) have been thought to be mititgated or impractically slow (Schwarzl et al., 2023) since the mitigation of mincore in 2019. In this paper, we not only revive practical attacks on the page cache, but also provide a systematic classification & understanding of primitives which interact with page cache. This understanding helps us advance page cache attacks, including speeding up previously known mechanisms by six orders of magnitude.
This artifact contains:
primitives/: Code implementing the four primitives: flush, monitor, reload, and evict,reload-mechanisms/: Proof-of-concept code demonstrating the 11 syscalls identifed as reload mechanisms,covert-channels/: Three covert channels, andattacks/: Three proof-of-concept attacks.
Our research was tested on Debian-based Linux systems with an ext4 file system. This artifact was developed on an Ubuntu 22.04 LTS and further verified on a Debian 13 system. Although our attacks should work on most Linux distributions, distro-specific idiosyncrasies might make it challenging to completely mount attacks in the exact manner described in this artifact. The underlying primitives, however, should work.
We also tested our attacks on ext2, ext3, btrfs, & ntfs, and confirmed that our mechanisms work on these systems; however, we have not tested anything more on these filesystems. Kernel versions above 4.14 should be enough to demonstrate all proof-of-concept attacks.
Kernel version requirements for syscalls:
- flush (
POSIX_FADVISE+POSIX_FADV_DONTNEED): > 2.6.5 - monitor (
preadv2+RWF_NOWAIT): > 4.14 - monitor (
cachestat): > 6.5*
In early 2025, the cachestat syscall was
mitigated
following our responsible disclosure. It appears this mitigation was introduced
in various patches of each distribution. The Debian security
tracker contains
links to other distributions' handling of the mitigation. In Ubuntu 22.04.1, the
mitigation appears to be introduced somewhere around patch version
62-65
and Ubuntu states to have fixed in it
6.8.0-64.67~22.04.1; so kernels
between 6.8.0 to 6.8.0-64 in Ubuntu 22.04 should support the non-mitigated
version cachestat as we demonstrate in the paper.
It is still possible to demonstrate the usage of cachestat, although not in a
cross-user threat model as we describe in the paper. The cachestat syscall
works for same-user demonstrations. Regardless, it is still possible to use the
preadv2 + RWF_NOWAIT as an alternative to cachestat, which we confirm works
at the time of writing.
glibc >= 2.34 is required.
For demonstrating cross-container attacks, the only additional piece of software required is Docker.
Most PoCs require a file. We recommend generating a file that's at least 64
pages large in /tmp/ using the script helper-scripts/create-testing-file.sh.
This shell script creates /tmp/ndss26-fallae-2.bin.
Note: Systems with /tmp/ mounted as a tmpfs (ex: Arch Linux) will not be
able to perform attacks on the file in /tmp/ as it does not get flushed to disk.
Please generate the file at a different path for the remainder of this artifact.
We suggest a system-wide file at /var/opt/. This file should be readable.
Validate the permissions on the temporary file (readable by user, group, and
other): ls -la /tmp/ndss26-fallae-2.bin
Since the attacks in our paper are cross-user, here is a command to create a new
user (requires root): adduser testinguser (set a simple password and the rest
can be skipped)
To clean up (requires root): deluser --remove-home testinguser
All commands to be run by testinguser will be explicitly mentioned. Otherwise, commands are to be run by the normal user (an already existing user).
Three primitives - flush, reload (read bypass backward reading), and reload
(read bypass readahead) - require a threshold value which is system dependent.
With an Intel CPU with P-Cores and E-Cores, it may be beneficial to taskset all
commands to one set of cores for the remainder of the evaluation: all commands
should be prefixed with taskset -c 0,1 if the P-Cores are cores 0 and 1. On
laptops, make sure the computer is plugged in and set to performance mode for
the least variability in values.
In calibration/, run make to compile the three programs.
Afterwards, run each of the programs a few times and determine a good threshold value. There may be outliers for the first runs; therefore, choose a value that often appears.
# Run 5 times and note a reasonable FLUSH_THRESHOLD
./build/calibrate-flush /tmp/ndss26-fallae-2.bin
# Run 5 times and note a reasonable READ_THRESHOLD
./build/read-bypass-backward-reading /tmp/ndss26-fallae-2.bin
# Run 5 times and note a reasonable READAHEAD_THRESHOLD
./build/read-bypass-readahead /tmp/ndss26-fallae-2.binPlace these values in the "configure me" part of primitives/Makefile.
The primitives/Makefile should now be configured with the selected thresholds.
In primitives/, run make. This compiles flush, monitor, reload, and evict
mechanisms into primitives/build/.
Copy build and scripts to /tmp/ and switch to the newly-created user.
cp -r build /tmp/build-primitives
cp -r scripts/ /tmp/build-primitives/
su testinguser
# [testinguser]
cd /tmp/From the paper, "flush is an unprivileged and deterministic primitive that removes pages from the page cache".
First, we bring the entire file into the page cache by reading the whole file:
# [testinguser]
./build-primitives/read /tmp/ndss26-fallae-2.bin -1 # -1 means the whole fileSecond, we flush the entire file. The cache field of each page should report
1, i.e. all pages should be in cache, as it takes a long time to execute the
syscall:
# [testinguser]
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1Finally, we flush the entire file once again. Note the cache field of each
page should now report 0, i.e. all pages should no longer be in cache because
we just flushed it, and it takes a short amount of time to execute the syscall:
# [testinguser]
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1From the paper, "Monitor [...] reports the presence of a page in the page cache
without bringing it into memory if it is not present". On kernel versions above
6.5, the cachestat
syscall can be used. As
mentioned in requirements above, cachestat may not work in a
cross-user setting and may get a permission denied. If that's the case,
preadv2 (discussed below) works.
Monitor does not require timing measurements.
# [testinguser]: All commands
# 1. Reload the whole file
./build-primitives/read /tmp/ndss26-fallae-2.bin -1
# 2. Monitor should report all pages in cache
./build-primitives/monitor-cachestat /tmp/ndss26-fallae-2.bin -1
# 3. Flush the whole file
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1
# 4. Monitor should report all pages not in cache
./build-primitives/monitor-cachestat /tmp/ndss26-fallae-2.bin -1It's possible to work around this limitation by installing an older, unpatched
kernel, or by using the preadv2 syscall:
# [testinguser]: All commands
# 1. Reload the whole file
./build-primitives/read /tmp/ndss26-fallae-2.bin -1
# 2. Monitor should report all pages in cache
./build-primitives/monitor-preadv2 /tmp/ndss26-fallae-2.bin -1
# 3. Flush the whole file
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1
# 4. Monitor should report all pages not in cache
./build-primitives/monitor-preadv2 /tmp/ndss26-fallae-2.bin -1From the paper, "The reload primitive determines whether a page is present in the page cache by measuring the time it takes a process to read a page". A big limitation with reloading page-by-page is the read-ahead mechanism: "As an optimization to increase performance, Linux, by default, reads upto 32 pages ahead upon any file access".
After flushing the entire file, reading the file page-by-page should indicate that all pages are not in cache. However, we notice the first few pages take a long time to be reloaded (i.e., not in cache) and subsequent pages take far less time to be reloaded (i.e., in cache).
# [testinguser]: All commands
# 1. Flush the whole file
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1
# 2. Reload the file using read
./build-primitives/read /tmp/ndss26-fallae-2.bin -1We present two methods to bypass this read-ahead mechanism.
The first and more reliable method to bypass the read-ahead mechanism is to read the file backward. Determining whether pages are in the page cache should now be reliable.
# [testinguser]: All commands
# 1. Flush the whole file
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1
# 2. Reload the file using backward reading
./build-primitives/read-bypass-backward-reading /tmp/ndss26-fallae-2.bin -1
# 3. Reload the file once again using backward reading (should now be in cache)
./build-primitives/read-bypass-backward-reading /tmp/ndss26-fallae-2.bin -1The second and less reliable method to bypass the read-ahead mechanism is by
employing the readahead syscall, "an explicit request to the read-ahead
mechanism to bring [exact] pages from disk".
# [testinguser]: All commands
# 1. Flush the whole file
./build-primitives/flush /tmp/ndss26-fallae-2.bin -1
# 2. Reload the file using readahead
./build-primitives/read-bypass-readahead /tmp/ndss26-fallae-2.bin -1
# 3. Reload the file once again using readahead (should now be in cache)
./build-primitives/read-bypass-readahead /tmp/ndss26-fallae-2.bin -1WARNING: This may slow your system down, freeze, or even crash it! Stability largely depends on how aggressive (fast) you employ eviction. Please close all applications before you try evaluate eviction. Ensure swapping is off!
In one terminal, open htop -d 1 or any monitoring program which reports memory
usage. In another terminal (as testinguser), bring the file to page cache before
starting the auto-kill-evict.sh:
# [testinguser]: All commands
# 1. Bring the file to page cache
./build-primitives/read /tmp/ndss26-fallae-2.bin -1
# 2. Monitor should report all pages in cache
./build-primitives/monitor-preadv2 /tmp/ndss26-fallae-2.bin -1
# 3. Start the auto-kill-evict script which kills all eviction programs once the
# file is out of page cache (uses monitor with preadv2)
./build-primitives/scripts/auto-kill-evict.sh /tmp/ndss26-fallae-2.binWith evict-3-baseline, we apply a baseline pressure by reducing free memory to
1/8 of available memory; we allocate a large amount of memory and mlock it.
Since mlock can lock up to 1/8 of total memory (on Debian & Debian-based
systems),
we spawn 7 baseline pressure programs locking 1/8 available memory, leaving
the remaining 1/8 for the three other eviction programs.
Different systems with different mlock limits (check with ulimit -l or ulimit -a) may require tweaking mlock limits (requires root).
This command tells a sane value to use with evict-3-baseline:
echo "scale=1; $(grep MemAvailable /proc/meminfo | awk '{print $2}') / (8 * 1000 * 1000)" | bcIn a new terminal (as testinguser), begin the process of eviction, replacing the
<BASELINE_HERE> with the number computed above. This computed number can be
made lower for eviction to be less aggressive.
# [testinguser]: All commands
# Eviction set 3
for ((i=0; i<7; ++i)); do
./build-primitives/evict-3-baseline <BASELINE_HERE> &
sleep 0.5
done
# Eviction set 1, iterate over all shared libraries
./build-primitives/evict-1-shlibs $(./build-primitives/scripts/list-all-shlibs.sh) &
# Eviction set 2, access all readable files on the system
./build-primitives/evict-2-randaccess &
# Eviction set 4, dynamically apply pressure by allocating 64 MiB chunks
./build-primitives/evict-4-dynpressure &When auto-kill-evict.sh reports "Killed all eviction programs...", the file
has been evicted. Validate this with monitor:
# [testinguser]
# 1. Monitor should report all pages are no longer in cache
./build-primitives/monitor-preadv2 /tmp/ndss26-fallae-2.bin -1There are 11 syscalls / methods that behave as reload mechanisms. These are
outline in Table 1 of the paper. In reload-mechanisms/, run make. Once all
11 files have been compiled (plus silent-flush.c), run test-all-reload.sh
(you don't need to be testinguser). This script tests all the reload mechanisms:
- starting with a flush
- reloading (slow, not in page cache)
- reloading (fast, in page cache)
We demonstrate three covert channels: Flush+Monitor, Flush+Reload, Flush+Flush.
First, set the FLUSH_THRESHOLD in covert-channels/flush-flush/Makefile and
READ_THRESHOLD in covert-channels/flush-reload/Makefile. These are determined
from calibration and were set in the Makefile in
primitives.
Afterwards, run make in all three directories. This results in three build
directories: build-cc-fm, build-cc-fr, and build-cc-ff. Copy these three
to /tmp/covert-channels/:
mkdir /tmp/covert-channels
# In covert-channels/flush-monitor:
cp -r build-cc-fm /tmp/covert-channels
# In covert-channels/flush-reload:
cp -r build-cc-fr /tmp/covert-channels
# In covert-channels/flush-flush:
cp -r build-cc-ff /tmp/covert-channelsWe create two files for the covert channels, one for transmission and the other for synchronization:
dd if=/dev/urandom of=/tmp/covert-channels/sync_file bs=1K count=64
dd if=/dev/urandom of=/tmp/covert-channels/comms_file bs=1M count=512For each of the three covert channels, the receiver runs first to flush the
comms_file from the page cache. Afterwards, the sender uses reload on
comms_file with backward reading to bring pages into the cache. Following the
transmission, the sender signals to the receiver via the sync_file to start
receiving. Finally, the receiver uses either monitor, reload, or flush on the
comms_file to determine the final message.
To compare the received message to the original transmitted message, the sender
writes the transmission to message.txt. Therefore, the sender requires write
permissions in that directory. The receiver reads this file once completed,
computes the percentage of correctly received bits.
Flush+Monitor Covert Channel:
# [testinguser]
cd /tmp/covert-channels/
# cachestat can also be used instead of preadv2
./build-cc-fm/receiver-preadv2 ./comms_file ./sync_file ./message.txt
# wait for 1-2 seconds
# [normal user]
cd /tmp/covert-channels/
./build-cc-fm/sender ./comms_file ./sync_file ./message.txtFlush+Reload Covert Channel:
# [testinguser]
cd /tmp/covert-channels/
# This may take up to some time to run, depending on disk and processor
# (12 seconds on AMD Zen 3 with SSD)
./build-cc-fr/receiver ./comms_file ./sync_file ./message.txt
# wait for 1-2 seconds
# [normal user]
cd /tmp/covert-channels/
./build-cc-fr/sender ./comms_file ./sync_file ./message.txtThe Flush+Flush Covert Channel is the most tricky to function with a low error rate as the time difference between flushing a page in cache versus a page not in cache is SUPER close. Furthermore, flush timing is extremely(!) sensitive to system activity.
As such, here are a few suggestions to try boosting its accuracy. We consistently see our covert channel perform above 99% correct transmitted bits with the first two tricks:
tasksetboth the sender and the receiver to different physical cores (same core complex).- Reduce the size of the comms_file. 512 MiB works fine on our AMD Zen 3 machine with an SSD, but other testing machines may require less (128 MiB).
- Tweak the FLUSH_THRESHOLD value if the receiver is already beginning to record data before the sender even starts transmitting.
NOTE: The prints are deliberate! On our system, we notice the print makes timing more concise (other tricks perform worse, such as sched_yield, usleeps...).
# [testinguser]
cd /tmp/covert-channels/
./build-cc-ff/receiver ./comms_file ../sync_file ./message.txt
# wait for 1-2 seconds
# [normal user]
cd /tmp/covert-channels/
./build-cc-ff/sender ../comms_file ../sync_file ./message.txtWe present three of the attacks we demonstrate in the paper as proof of concepts.
We detect whether htop has been executed. First, ensure there are no htops
already running: pkill htop. Second, ensure the output of which htop
succeeds with a path (and is not aliased). Finally, we use the flush and monitor
primitives earlier compiled to detect htop.
As testinguser:
# [testinguser]: All commands
# Flush htop
/tmp/build-primitives/flush $(which htop) -1
# Monitor htop
while true; do
/tmp/build-primitives/monitor-preadv2 $(which htop) -1 | grep "In cache"
doneIn another shell as normal user, run htop. The while loop should now report
that htop is in cache.
In attacks/docker/, there are two folders: provoker and watcher. Watcher
contains code for a Flush+Reload program. In both provoker/ and watcher/,
run docker compose up. This will build two images ndss26-fallae-2-provoker
and ndss26-fallae-2-watcher from ubuntu:jammy. It will also install oh-my-zsh.
Once both images are built, run these two commands:
# In terminal 1
docker run -it ndss26-fallae-2-watcher:latest
ls -l
./watcher /usr/bin/ls 4 # Watching only page 4
# In terminal 2
docker run -it ndss26-fallae-2-provoker:latest
# wait for a few seconds
lsWhenever ls is run in terminal 2 (provoker), the watcher program in terminal 1
should see it reload faster, i.e. cached.
With this attack, we aim to show the difference in total number of cached pages
for a few websites. With snap versions of Firefox on Ubuntu being challenging to
deal with, we recommend using a non-snap version of Firefox. If Firefox is
already installed with apt, find the path of libxul.so (you can use the
plocate command to help): it most likely will be /usr/lib/firefox/libxul.so.
Download a non-snap version of Firefox from Mozilla's PPA: https://ppa.launchpadcontent.net/mozillateam/ppa/ubuntu/pool/main/f/firefox/. The package to be downloaded should be all the way at the bottom, and it should look like: firefox_1XX.0.4+build1-0ubuntu0.22.04.1~mt1_amd64.deb. The file size should be > 50-ish MB. DON'T download a locale version.
Once downloaded, extract the file: ar x firefox_....deb. Afterwards, extract
data.tar.zst: tar -xf data.tar.zst. In the newly extracted data folder, edit
data/usr/lib/firefox/firefox.sh to change the path of MOZ_LIBDIR to the full
path of the directory in which the firefox.sh is present. This directory
should also contain libxul.so. This path is required for the attack.
With this, we now have a local version of firefox that can be launched via:
./firefox.sh.
In attacks/website-fingerprinting, run make to compile the flush-monitor
program, which uses preadv2. For simplicity, you can be normal user to evaluate:
./flush-monitor /path/to/libxul.so # earlier noted with the firefox.shIn the locally-launched firefox, visit a few websites (one at a time) to see a difference in usage of libxul's pages. Try openai, twitch, reddit, wikipedia, youtube, hackernews.