Introduction: The Comfortable Lie
There's a comfortable story developers tell themselves:
"I'm using a modern framework. It handles all that low-level security stuff for me."
And to be fair - it's not entirely wrong. Frameworks like Spring Boot, Django, Laravel, and Angular have matured significantly. They come with CSRF protection, ORM-based SQL injection prevention, output encoding, and a dozen other defaults that would have required manual implementation a decade ago.
But here's the uncomfortable truth:
Frameworks protect the paths they know about. They can't protect the ones you build yourself.
Local File Inclusion (LFI) - a vulnerability older than most of today's developers - is not dead. It hasn't been patched away by framework evolution. It has simply migrated. It now lives inside your custom business logic, your legacy integrations, your "quick-and-dirty" file downloader endpoint, and your dynamic module loader.
This post is about finding it there.
Part 1: What LFI Actually Is (And Isn't)
The Textbook Definition
Local File Inclusion occurs when a web application uses user-controlled input to construct a file path, then reads or includes that file - without properly validating that the path stays within the intended directory.
The classic demonstration:
# Vulnerable URL
https://example.com/view?file=report.pdf
# Attacker-controlled URL
https://example.com/view?file=../../../../etc/passwd
The ../ sequences traverse up the directory tree, escaping the intended /uploads/ folder and reaching sensitive system files.
What LFI Can Lead To
-
Sensitive file exposure -
/etc/passwd,.env,web.xml,application.properties - Source code disclosure - reading your own application's config and logic
- Credential theft - database passwords, API keys in config files
- Log poisoning → RCE - injecting PHP/code into log files, then including them
-
SSRF chaining - using
file://wrappers to pivot to internal services
Why "Modern Framework" Doesn't Mean "Safe"
Frameworks protect framework-managed routes. The moment you write a custom controller, service, or utility that touches the filesystem with user input - you're on your own.
Let's walk through exactly how this happens.
Part 2: The Real-World Attack Surface - HMIS and Legacy Integrations
Why HMIS Is a Perfect Storm
Health Management Information Systems (HMIS) and similar enterprise platforms are uniquely vulnerable for three reasons:
- Legacy at the core - Many are built on decade-old codebases, now wrapped in a modern Angular or React frontend that looks modern but calls ancient backend endpoints.
- Heavy file operations - Patient records, lab reports, imaging files, insurance documents. File I/O is not an edge case; it's central to the domain.
- Custom everything - The business logic is so domain-specific that almost nothing is handled by the framework. Custom downloaders, custom report generators, custom module loaders.
A typical pattern in such systems:
GET /api/reports/download?reportPath=2024/Q1/patient_summary.pdf
This looks harmless. But the backend is doing something like:
// Java / Spring Boot - simplified
String basePath = "/opt/app/reports/";
String fullPath = basePath + request.getParameter("reportPath");
File file = new File(fullPath);
// stream file to response...
And the attacker sends:
GET /api/reports/download?reportPath=../../../../etc/passwd
Game over.
Part 3: Technical Deep-Dive - Angular + Spring Boot Patterns
Scenario 1: The Custom File Downloader (Spring Boot)
This is the most common LFI vector in enterprise Java applications.
Vulnerable Code
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(
@RequestParam String filename,
HttpServletResponse response) {
String basePath = "/var/app/uploads/";
Path filePath = Paths.get(basePath + filename); // ← VULNERABLE
Resource resource = new FileSystemResource(filePath.toFile());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
}
Why It's Vulnerable
Paths.get(basePath + filename) does not normalize the path. An input of ../../../etc/shadow simply concatenates to /var/app/uploads/../../../etc/shadow, which the OS resolves to /etc/shadow.
The Bypass - Encoding Tricks
Even if a naive check like filename.contains("../") is added, attackers bypass it:
| Bypass Technique | Payload |
|---|---|
| URL encoding | %2e%2e%2f%2e%2e%2fetc%2fpasswd |
| Double encoding | %252e%252e%252f |
| Unicode normalization | ..%c0%af..%c0%afetc/passwd |
| Null byte (legacy) | ../../../etc/passwd%00.pdf |
| Absolute path |
/etc/passwd directly |
Many developers check for ../ but forget to normalize/decode first.
Secure Fix
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(
@RequestParam String filename) throws IOException {
Path baseDir = Paths.get("/var/app/uploads/").toRealPath();
Path requestedFile = baseDir.resolve(filename).normalize().toRealPath();
// THE CRITICAL CHECK - ensure resolved path is inside baseDir
if (!requestedFile.startsWith(baseDir)) {
throw new SecurityException("Path traversal attempt detected");
}
Resource resource = new FileSystemResource(requestedFile.toFile());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + requestedFile.getFileName() + "\"")
.body(resource);
}
Key: .toRealPath() resolves symlinks and .. sequences at the OS level. .startsWith(baseDir) then guarantees confinement.
Scenario 2: The Dynamic Module Loader (Angular Frontend + Node/Java Backend)
Enterprise applications - especially HMIS - often implement plugin-like architectures where modules are loaded dynamically based on user role or configuration.
The Pattern
The Angular frontend requests which module to load:
// Angular service - simplified
loadModule(moduleName: string): Observable<any> {
return this.http.get(`/api/modules/load?name=${moduleName}`);
}
The backend serves it:
// Spring Boot backend
@GetMapping("/api/modules/load")
public String loadModuleConfig(@RequestParam String name) throws IOException {
String configPath = "/opt/app/modules/" + name + "/config.json";
return new String(Files.readAllBytes(Paths.get(configPath))); // ← VULNERABLE
}
The Attack
GET /api/modules/load?name=../../../../etc/spring/datasource
If datasource.json or similar config files exist, the attacker reads your database credentials.
More Dangerous: Template/Script Loaders
If the dynamic loader serves .js, .html, or .ftl (FreeMarker template) files:
GET /api/modules/load?name=../../../../var/log/app/access (after log poisoning)
With log poisoning, an attacker first injects a payload into logs, then uses LFI to include and execute it - achieving Remote Code Execution.
Secure Fix Pattern
// Whitelist approach - THE safest option
private static final Set<String> ALLOWED_MODULES = Set.of(
"dashboard", "patients", "billing", "reports", "inventory"
);
@GetMapping("/api/modules/load")
public String loadModuleConfig(@RequestParam String name) throws IOException {
// 1. Whitelist validation
if (!ALLOWED_MODULES.contains(name)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Module not permitted");
}
// 2. Even with whitelist, still confine the path
Path baseDir = Paths.get("/opt/app/modules/").toRealPath();
Path configFile = baseDir.resolve(name).resolve("config.json")
.normalize().toRealPath();
if (!configFile.startsWith(baseDir)) {
throw new SecurityException("Path traversal attempt");
}
return Files.readString(configFile);
}
Part 4: LFI Through Indirect Vectors - The Ones You Miss
4.1 File Upload + LFI Combination
An attacker uploads a file named ../../../../etc/cron.d/malicious (if the server doesn't sanitize upload destinations). The upload itself is the traversal.
// VULNERABLE upload handler
String uploadDir = "/var/uploads/";
String filename = file.getOriginalFilename(); // ← attacker-controlled
Path destination = Paths.get(uploadDir + filename);
Files.copy(file.getInputStream(), destination);
Fix: Always use Paths.get(uploadDir).resolve(Paths.get(filename).getFileName()) - .getFileName() strips any path components, keeping only the bare filename.
4.2 PDF / Report Generators
Many applications pass user-controlled values into HTML templates that are then rendered to PDF (using tools like iText, Puppeteer, or wkhtmltopdf).
<!-- Template with user-supplied file path -->
<img src="file:///{{ userSuppliedPath }}" />
If a headless browser renders this, it reads the local file and embeds it in the PDF returned to the attacker. This is a file read via PDF generation - a lesser-known LFI variant.
4.3 Log Poisoning → LFI → RCE (The Full Chain)
This is the most dangerous escalation path:
Step 1 - Poison the log:
GET /index.php?<?php system($_GET['cmd']); ?>
(This gets written into /var/log/apache2/access.log)
Step 2 - Include the log via LFI:
GET /view?file=../../../../var/log/apache2/access.log&cmd=id
Step 3 - Code executes, returns:
uid=33(www-data) gid=33(www-data)
Modern PHP apps are most vulnerable to this, but any server-side template engine that evaluates included file content can be exploited similarly.
Part 5: Detection - How to Find LFI in Your Own Codebase
Static Analysis Patterns to Hunt
Search your codebase for these anti-patterns:
# Find file operations using request parameters (Java)
grep -rn "getParameter\|getParam\|RequestParam" --include="*.java" . \
| grep -i "file\|path\|load\|download\|resource\|module"
# Find path concatenation (generic)
grep -rn "basePath +\|basePath\.concat\|+ fileName\|+ filePath" \
--include="*.java" --include="*.js" --include="*.ts" .
# Find Files.readAllBytes or similar with non-whitelisted input
grep -rn "readAllBytes\|readString\|FileInputStream\|FileSystemResource" \
--include="*.java" .
DAST - Dynamic Testing Payloads
When testing your own endpoints, try:
# Basic traversal
../../../../etc/passwd
# Encoded variants
%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd
# Windows targets
..\..\..\..\windows\win.ini
..\..\..\..\/windows/win.ini
# Null byte (for older systems)
../../../../etc/passwd%00.jpg
# Absolute path
/etc/passwd
/proc/self/environ
/proc/self/cmdline
# Application-specific targets
../../WEB-INF/web.xml
../../application.properties
../../.env
../../config/database.yml
Interesting Files to Target Per Stack
| Stack | High-Value Target |
|---|---|
| Spring Boot |
application.properties, application.yml
|
| Node.js |
.env, config/default.json
|
| PHP |
config.php, wp-config.php
|
| Django | settings.py |
| Any Linux |
/proc/self/environ, /etc/passwd
|
| Any |
.git/config, .ssh/id_rsa
|
Part 6: Defense Checklist
Not a bullet point post - so here it is as a practical checklist you can actually use in code reviews:
✅ Path Confinement
Always resolve the full real path and verify it starts with your intended base directory. Use .toRealPath() (Java), realpath() (PHP/C), path.resolve() + manual prefix check (Node.js).
✅ Input Whitelisting Over Blacklisting
Never maintain a blocklist of bad characters. Maintain an allowlist of permitted file names, module names, or IDs. Map IDs to file paths server-side; never let the user specify the path directly.
✅ Decode Before Validating
Always URL-decode input before running any path checks. Attackers encode specifically to bypass string-matching filters.
✅ Separate File Storage From Application Root
Store user-uploaded or user-accessible files on a completely separate volume or object storage (S3, GCS). Files that can't be included by the application can't cause LFI.
✅ Principle of Least Privilege
Run your application process as a user with minimal filesystem permissions. Even if LFI exists, the attacker can only read files the process can read.
✅ Disable Directory Listing
Directory listing combined with LFI dramatically accelerates information gathering for attackers.
✅ Log and Alert on Path Traversal Attempts
Any request containing ../, %2e%2e, or absolute paths to sensitive directories should trigger an alert - not just a 403.
Conclusion: The Framework Didn't Betray You. You Betrayed Yourself.
Local File Inclusion persists not because framework developers are negligent - they've done considerable work. It persists because every custom line of business logic you write is, by definition, outside the framework's protection perimeter.
The LFI of 2025 doesn't look like the PHP include($_GET['page']) of 2005. It looks like:
- A well-structured Spring Boot controller with a single unsanitized
Paths.get()call - An Angular-driven dynamic module loader that passes module names to a backend file reader
- A PDF export feature that threads user input through a template engine with
file://access
The code looks professional. The architecture diagram looks modern. The vulnerability is ancient.
Every time you write code that touches the filesystem with user-controlled input, ask yourself:
"Have I resolved the full real path? Have I verified it stays inside my intended directory? Am I using a whitelist?"
If the answer to any of those is no - you have just written an LFI vulnerability. Doesn't matter what framework you're in.
References & Further Reading
- OWASP - Path Traversal
- OWASP Testing Guide - LFI (OTG-INPVAL-011)
- PortSwigger Web Security Academy - File Path Traversal
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- Java
Path.toRealPath()Documentation
About the Author
If you found this useful, share it with your team - especially anyone writing custom file-serving endpoints. The best security fix is the one that happens before the breach.
Found a variant of this in the wild? Drop it in the comments - let's build a community knowledge base.
Tags: #security #webdev #appsec #hacking #backend #java #angular #lfi #pathtraversal #cybersecurity
Top comments (0)