Skip to content

Cheatsheet

The behavioural source of truth for this site. Every cell in the first table is checked against the payload builder at build time — if the table and the generator ever disagree about a verdict, the build fails.

Does the inclusion fire?

Rows are the wrapper and the sink it is aimed at. Columns are the target's php.ini. Depends means something outside the config decides it — read the note, and say which condition held in your finding.

Wrapper into sinkallow_url_include=OnPHP 8 defaultallow_url_fopen=Offopen_basedir + allowlist
Plain traversal path into include()WorksWorks

Nothing in the default php.ini stops a local path. This is the whole reason LFI outlived RFI.

WorksBlocked

The allowlist is what stops it. open_basedir alone only confines the traversal to the app tree — which still contains the config file.

Plain traversal path into file_get_contents()WorksWorks

Read-only. Arbitrary file disclosure, not code execution — rate it as disclosure unless you can chain it.

WorksBlocked
http:// remote file into include()Works

The textbook RFI. This column is the only one where it has ever worked without a deliberate config change.

Blocked

allow_url_include has shipped Off since PHP 5.2.0 in 2006. Every tutorial teaching this payload as current is describing an eighteen-year-old default.

BlockedBlocked
http:// remote file into file_get_contents()WorksWorks

The split everyone misses: reading a remote URL needs only allow_url_fopen, which is On by default. This is SSRF, and it is live on a stock config.

Blocked

Turning allow_url_fopen off is what actually kills the remote fetch. Turning off allow_url_include never did.

Blocked
ftp:// remote file into include()WorksBlocked

Same gate as http://. Worth one attempt only when the denylist in front of you matches on the string "http".

BlockedBlocked
data:// inline payload into include()Works

No outbound connection needed, which is why it survives an egress filter that kills http://.

Blocked

Gated by allow_url_include exactly like http://, despite never touching the network. The name of the flag is misleading.

BlockedBlocked
data:// inline payload into file_get_contents()WorksWorks

Reading a data: URI needs only allow_url_fopen. open_basedir has nothing to confine here — no filesystem is touched.

BlockedBlocked
php://filter base64 source read into include()WorksWorks

php://filter is not governed by allow_url_include. It runs on the shipped default, which is why source disclosure is the reliable first move in 2026.

WorksDepends

resource= still resolves through the filesystem, so open_basedir confines it to the app tree. That tree usually still contains the config file you wanted.

php://filter base64 source read into file_get_contents()WorksWorks

A read-only sink is enough — the filter does the work, the sink just returns the bytes.

WorksDepends
php://filter iconv chain into include()WorksWorks

The live 2026 escalation path. Turns a read primitive into RCE on a completely default php.ini, with no upload, no writable directory, and no outbound connection.

WorksDepends

The chain itself is unaffected, but resource=php://temp and the final include still have to satisfy open_basedir.

php://filter iconv chain into file_get_contents()Blocked

The chain generates bytes; something still has to execute them. Against a read-only sink it builds fine and achieves nothing.

BlockedBlockedBlocked
php://input request body into include()Works

The POST body becomes the included file. No file has to exist anywhere.

Blocked

Local in every practical sense, yet still gated by allow_url_include. This surprises people every time.

BlockedBlocked
expect:// direct command into include()Depends

The gate is a missing extension, not an ini flag — expect is PECL, not bundled, and is almost never installed. No config column can honestly say "works".

DependsDependsBlocked
zip:// into an uploaded archive, into include()WorksWorks

A local wrapper, so no allow_url_* gate applies. What you need is an upload whose path on disk you can predict.

WorksDepends

The archive has to sit inside the basedir — which an upload directory normally does.

phar:// into an uploaded archive, into include()WorksWorks

A local wrapper, so no allow_url_* gate applies. phar.readonly=On — the default — prevents writing archives, not reading uploaded ones, and is not a mitigation here.

WorksDepends

The archive has to sit inside the basedir, which an upload directory normally does.

phar:// into file_get_contents() — metadata deserializationDepends

The reason phar:// gets its own guide: the archive metadata is unserialized the moment the stream is touched, so even a read-only sink is a gadget-chain entry point. Conditional because it needs a usable gadget in the loaded classes.

DependsDependsDepends
glob:// directory enumerationDepends

Never better than conditional: glob:// yields a directory stream, and include() cannot consume one. It is reconnaissance through opendir/scandir, not an inclusion.

DependsDependsDepends
file:// explicit local scheme into include()WorksWorks

Identical in effect to a bare path, but survives a filter that only rejects strings starting with ../ or /.

WorksBlocked
\\host\share UNC path into include() (Windows)Depends

On Windows a UNC path goes through the filesystem layer, so allow_url_include never sees it. Conditional in every column because it needs a Windows target and outbound 445 through the egress filter — two things no php.ini setting can assert.

DependsDependsBlocked
Log poisoning — write via User-Agent, then include the logDepends

Not a wrapper, so there is nothing to derive: it needs a log the worker can read, which on a modern distro is usually root-owned and mode 0640.

Depends

Unaffected by any allow_url_* setting — the write goes through the web server and the read is a plain local path.

DependsBlocked

open_basedir confines the read to the app tree, and /var/log is not in it.

PHP_SESSION_UPLOAD_PROGRESS temp-file raceDepends

A timing attack, not a configuration outcome — prose-only here because no static verdict is honest about a race.

Depends

Works on a default config, and does not need session.upload_progress.enabled to still be On in every build. Verify before relying on it.

DependsBlocked

The three things this table exists to settle

  • allow_url_fopen and allow_url_include are not the same flag. Reading a remote URL needs the first, which is On by default. Executing one needs the second, which is not. That single distinction is why file_get_contents('http://…') is live SSRF on a stock host while include('http://…') has been dead since 2006.
  • php://filter is not governed by allow_url_include. It runs on the shipped default. This is the whole reason the modern escalation path goes through a filter chain rather than a remote fetch.
  • Whether the sink executes decides the severity, not the payload. The same traversal is disclosure through readfile() and RCE through include().

What this table does not cover

  • Whether your input reaches the start of the string. Every wrapper row assumes it does. include('pages/' . $page) defeats all of them at once, because a scheme only counts at position zero.
  • An appended extension. Assume none; see Extension Append Bypasses for what survives one.
  • The last two rows are prose. Log poisoning and the temp-file race are not wrapper outcomes, so the build script counts them and skips them rather than pretending to derive a verdict.

The same bug in seven runtimes

PHP is the only column with a stream-wrapper layer, which is why it is the only one where a filename can become a URL. Everywhere else, file inclusion degrades to path traversal — still a finding, rarely code execution.

PropertyPHP 8.4Node 22 / Express 5Java 21 / Servlet 6ASP.NET Core 9Python 3.13 / FlaskRails 8Go 1.23 (net/http)
Sink that executes what it loadsinclude, require, include_once, require_oncerequire(), dynamic import()RequestDispatcher.include/forward, <jsp:include>Razor view-name resolution, return View(name)Jinja2 get_template(), importlib.import_module()render file:, render template:None — Go has no dynamic include. This is the single biggest reason Go apps do not get RFI.
Read-only sinkfile_get_contents, readfile, fopen, SplFileObjectfs.readFile, res.sendFile, express.staticFiles.readString, new File, ClassPathResourceFile.ReadAllText, PhysicalFileProvideropen(), pathlib.Path.read_text()File.read, send_fileos.ReadFile, http.ServeFile
Can a URL scheme reach the file sink?Yes — the stream-wrapper layer makes every registered scheme reachable from every filesystem function. This is what makes PHP different from all six columns to its right.No for fs. ESM import() does accept data: URLs, which is a narrow but real execute path.No — no wrapper layer. URL fetches are a separate sink and a separate finding.No.No.No.No.
The path-joining footgunPlain concatenation. Nothing normalizes for you.path.join('views', x) collapses ../ before the filesystem sees it, so the escape happens inside the join.new File(dir, x) does not normalize; the servlet container normalizes the dispatcher path but not your File.Path.Combine(a, b) discards a entirely when b is absolute. Passing /etc/passwd wins outright.os.path.join(a, b) discards a entirely when b is absolute — the same footgun as .NET.File.join does not normalize; render file: has required an absolute path since Rails 5.filepath.Join cleans the result, which removes ../ but does not confine it to a root.
Null byte in a pathRejected. ValueError since PHP 8; truncation was fixed in 5.3.4 (2010).Rejected — ERR_INVALID_ARG_VALUE.Rejected since Java 7.Rejected — ArgumentException.Rejected — ValueError: embedded null byte.Rejected — ArgumentError.Rejected — invalid argument.
What the framework blocks by defaultallow_url_include=Off (since 5.2.0). Nothing else — local paths are entirely your problem.express.static and res.sendFile with a root option confine the path. Manual concatenation does not.Tomcat rejects ../ in getRequestDispatcher; Spring's PathResourceResolver blocks traversal.PhysicalFileProvider rejects .. segments and absolute paths.send_from_directory calls safe_join; Jinja's FileSystemLoader rejects .. segments.ActionView raises on a traversal or absolute path in a render argument.http.ServeFile explicitly rejects any path containing .., and http.Dir does the same.
What actually stops itAn allowlist map from parameter to filename. Second best: realpath() plus a prefix check. open_basedir is defence in depth, not a fix.path.resolve then a startsWith check on the resolved root — not a string filter on the input.Path.normalize then startsWith on the canonical base directory.Path.GetFullPath then StartsWith on the canonical root.os.path.realpath plus os.path.commonpath, or werkzeug's safe_join.An allowlist of renderable template names. Never interpolate a parameter into render.http.Dir with FileServer, or filepath.Clean plus an explicit prefix check.