joaovicdev@sec:~$

$ cat cve-2026-44119-apache-htaccess-expr-file-read.mdx

CVE-2026-44119: Reading files as the httpd user through .htaccess expressions

[cve][apache][research]

Disclosure: this issue was reported to the Apache HTTP Server security team, fixed in 2.4.68, and is public as of the 2026-06-08 advisory, if you run httpd you should upgrade since there is no configuration-only workaround, and I'm credited as one of several independent finders on this CVE.

Summary

CVE-2026-44119 is a moderate privilege issue (CWE-269, Improper Privilege Management) in Apache HTTP Server up to and including 2.4.67, it lets a low-trust .htaccess author, think a tenant on a shared host who can drop .htaccess files but does not control the main server config, read files with the privileges of the httpd process, not their own.

The root cause is small and precise, Apache's expression engine (ap_expr) tags a handful of functions and operators as restricted because they touch the filesystem: the content-reading functions file(), filesize(), and filemod(), plus the file-test operators (-d -e -f -s -L -h -x).

When an expression is parsed in an untrusted context the parser is supposed to receive a restricting flag and refuse those primitives, but several directives that accept expressions forgot to pass any such flag when the expression came from a .htaccess file, so file(), which reads a file's contents as the server user, became callable from per-directory config that untrusted users are allowed to write.

Because more than one directive shared the mistake, the advisory is titled "escalation of privilege through expressions in .htaccess in multiple modules", the directives first patched by name are RewriteCond (mod_rewrite), SetEnvIfExpr (mod_setenvif), and ProxyFCGISetEnvIf (mod_proxy_fcgi), but as the fix below shows the problem was never limited to those three, it lived in the shared parse path every expression directive funnels through, and this writeup uses ProxyFCGISetEnvIf as the worked example.

Affected versions

ProductAffectedFixedCVSS 3.1
Apache HTTP Server2.4.0 – 2.4.672.4.685.5 (Medium)

Official vector (NVD):

CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Two parts of that vector are worth reading closely, because they explain why a "read any file the server can read" bug scores only 5.5:

  • AV:L / PR:L: the precondition is being able to author a .htaccess file that the server will honor, that's a local, already-somewhat-privileged position (a shared-hosting tenant, a CI job, a user with write access to a served directory), not an anonymous internet attacker.
  • C:H / I:N / A:N: the impact is pure confidentiality, you can read, you can't write or crash anything through this path.

Background: ap_expr and the restricted flag

ap_expr is the little expression language sprinkled across Apache config, <If>, RewriteCond, Require expr, Header ... expr=, SetEnvIfExpr, and friends, it evaluates in the httpd worker, which means any function it exposes runs with the worker's uid (commonly www-data or apache).

Apache's config trust model has two tiers:

  • Main config (httpd.conf, vhosts) is written by the administrator and is fully trusted.
  • .htaccess is per-directory config, on shared hosts it is routinely writable by users who are not the administrator, and it is trusted only as far as AllowOverride lets it be.

Some ap_expr primitives are safe to expose to trusted config but dangerous in untrusted config, so they're marked restricted in the documentation, they fall into two groups and the distinction turns out to matter for the fix:

  • Content-reading functions: file('/path') returns a file's contents, filesize('/path') its size, filemod('/path') its modification time.
  • File-test operators: -f, -d, -e, -s, -L, -h, -x, existence / type / permission tests on a path, these leak metadata, not contents.

The enforcement mechanism is a flag on the parser, when a caller parses with ap_expr_parse_cmd(..., AP_EXPR_FLAG_RESTRICTED, ...) the parser refuses the restricted primitives at parse time, mod_include already used this flag for server-side-includes, where it blocks both groups, but .htaccess, it turned out, passed no restricting flag at all, so both groups were reachable.

Root cause

Look at how a directive turns its argument string into a compiled expression, the vulnerable pattern parses with no flags regardless of where the directive came from:

modules/proxy/mod_proxy_fcgi.c (vulnerable, pre-2.4.67)
static const char *cmd_setenv(cmd_parms *cmd, void *in_dconf,
                              const char *arg1, const char *arg2,
                              const char *arg3)
{
    /* arg1/arg3 are attacker-controlled when this lives in a .htaccess */
    new->cond  = ap_expr_parse_cmd(cmd, arg1, 0, &err, NULL);           // no restricting flag
    new->subst = ap_expr_parse_cmd(cmd, arg3,
                                   AP_EXPR_FLAG_STRING_RESULT, &err, NULL);
    ...
}

The fix keys off a property of cmd_parms: when a directive is processed inside a .htaccess, its pool is the temp_pool, that single comparison distinguishes trusted main-config parsing from untrusted per-directory parsing, but where the comparison lives, and which flag it sets, changed between two attempts.

The first attempt (trunk r1933349, shipped in 2.4.67) added that comparison directly to the three named directives, setting AP_EXPR_FLAG_RESTRICTED in the .htaccess case, and it was both incomplete and too blunt, incomplete because other expression directives like Require expr, Header ... expr=, and <If> also parse ap_expr in .htaccess and were left untouched, which is precisely why 2.4.67 is still listed as affected, and too blunt because AP_EXPR_FLAG_RESTRICTED also disables the file-test operators, which would break the ubiquitous RewriteCond %{REQUEST_FILENAME} !-f idiom that a huge share of the web relies on.

The real fix (trunk r1935016, backported to 2.4.x as r1935017, shipped in 2.4.68) removes those per-directive guards and instead sets a narrower flag in the one function every directive's ap_expr_parse_cmd() call routes through:

include/ap_expr.h (fixed): a new, narrower flag
/** Don't allow functions/vars that expose content from the filesystem. */
#define AP_EXPR_FLAG_RESTRICTED_FILE_FUNC   16
server/util_expr_eval.c, ap_expr_parse_cmd_mi() (fixed, central)
info->flags = flags;
info->module_index = module_index;
 
/* Use restricted-contents ap_expr() parser in htaccess context. */
if (cmd->pool == cmd->temp_pool) {
    info->flags |= AP_EXPR_FLAG_RESTRICTED_FILE_FUNC;
}

Because ap_expr_parse_cmd() is just a wrapper around ap_expr_parse_cmd_mi(), that one guard restricts file(), filesize(), and filemod() for every directive parsed from a .htaccess, the genuine "multiple modules" fix, while -f, -d, and the other file-test operators keep working, because the new flag is wired to match only the content-reading functions:

server/util_expr_eval.c, core_expr_lookup() (fixed)
if (((parms->flags & AP_EXPR_FLAG_RESTRICTED)                 /* mod_include: all */
     && (prov->restricted & RESTRICTED_ALL))
    || ((parms->flags & AP_EXPR_FLAG_RESTRICTED_FILE_FUNC)    /* .htaccess: funcs only */
        && (prov->restricted & RESTRICTED_FILE_FUNC))) {
    *parms->err = apr_psprintf(parms->ptemp,
                               "%s%s not available in restricted context", ...);
}

Exploitation

The clean threat model is shared hosting with a FastCGI backend:

  • The tenant can write .htaccess in their docroot, and the host has AllowOverride FileInfo (or more) enabled, otherwise .htaccess is ignored entirely and none of this is reachable.
  • httpd runs as www-data, the tenant's PHP-FPM pool runs as the tenant's own uid, so there are files readable by www-data that the tenant's own code cannot open.

ProxyFCGISetEnvIf sets environment variables that are handed to the FastCGI process just before the request is proxied, and both its condition and its value are ap_expr expressions, put a restricted file read in the value and Apache reads the file for you, as www-data, then hands you the bytes in an env var:

.htaccess (illustrative minimal PoC)
# httpd evaluates file() as www-data and stuffs the contents into LEAK,
# which is then delivered to the FastCGI app the tenant controls.
ProxyFCGISetEnvIf "true" LEAK "%{file:/var/www/other-tenant/config/secrets.env}"
index.php (attacker-controlled FastCGI app)
<?php
// Runs as the tenant, but LEAK already holds bytes read by www-data.
echo $_SERVER['LEAK'];

The privilege boundary crossed is exactly the one in the CVE title: the tenant never gains code execution or write access, but they get read access to anything the httpd user can read, other tenants' configs, credentials, keys, files deliberately kept out of the tenant's own uid, and the other affected directives give the same primitive by different means, RewriteCond can reflect file() output into a redirect Location, and SetEnvIfExpr can turn a comparison like file('/path') =~ /needle/ into a boolean oracle for byte-at-a-time blind extraction.

The snippet above is an illustrative minimal reproduction built from the documented directive semantics, not a capture from a specific target, only test it against infrastructure you own or are authorized to assess.

How I found it

I've been building an AI-assisted research workflow and pointing it at Apache's C codebase, for this class of bug the leverage was in surface mapping and hypothesis generation: enumerate every directive that feeds a string into ap_expr, then ask a sharper question than "is this exploitable": which of these parse sites propagate the restricted flag into the untrusted .htaccess path, and which silently drop it?, which turns a vague audit into a checklist.

The automation is a force multiplier, not an oracle, every candidate has to be reproduced and confirmed by hand before it's worth anyone's time, an LLM will happily narrate a convincing bug that doesn't exist, so a hypothesis isn't real until it's been reduced to a minimal config and observed doing the wrong thing in a controlled environment, this one survived that bar, several others I chased did not, and it's also a collision bug, ten of us are credited as independent finders on the advisory, a good reminder that "found with tooling" and "found first" are different achievements.

Timeline

  1. 2026-04-26: a first, partial hardening lands on trunk (r1933349) and is backported the same day, it ships in 2.4.67 but does not fully close the hole.
  2. 2026-05-05: reported to the Apache HTTP Server security team.
  3. 2026-06-05: the complete fix lands on trunk (r1935016) and is backported to 2.4.x (r1935017).
  4. 2026-06-08: 2.4.68 released, advisory posted to oss-security.
  5. 2026-07-12: this writeup.

References