[CVE-2026-94802] Unauthenticated command execution in Casdoor

CVE-2026-94802: Unauthenticated command execution in Casdoor

On 29 September 2026 at 17:49, I finally received the email I had been waiting for since March. MITRE had assigned CVE-2026-94802 to the issue I reported in Casdoor.

The request had been logged on 21 March. That is 192 days between the CVE request and the assignment email, kinda crazy. I never received a response to my disclosure email, so checking the code myself from time to time, was how I learned that they actually silently fixed it in the meantime.

The technical issue was much more immediate. An anonymous (unauthed) request could reach a server-side command runner, pass a hash check that required no secret, and make a Casbin CLI read a server file. File content then came back in an error message.

I validated that path on Casdoor's own public demo, door.casdoor.com. It did not require a user account or an API key.

Why this caught my attention

Casdoor is an open-source identity and single sign-on platform. Applications use it to handle logins, users and authentication. That makes its server a particularly bad place to expose a command runner to anonymous visitors.

Casdoor also presents itself as software used by large organisations. Its public showcase displays names including Microsoft, Intel, Tencent, IBM and ETH Zurich. As someone from Switzerland, ETH stood out to me.

The reason this issue matters is where the software sits. An identity server handles the trust between users and applications. A vulnerability there can put account data, application credentials and signing material at risk, depending on the deployment and the access an attacker gains.

The endpoint behind it

The code I reviewed was Casdoor v2.363.0, at commit 65755d3b. The relevant endpoint was:

Code:
GET /api/run-casbin-command

It lets the backend run Casbin command-line tools for different languages. A client supplies a language and a JSON array of arguments. The server finds the matching CLI binary, runs it and returns the output.

That feature explains why a command runner existed. The problem was how much an anonymous caller could control.

In the authorisation policy, the endpoint had this rule:

Code:
p, *, *, GET, /api/run-casbin-command, *, *

The wildcard subject fields allowed anonymous requests through. In the version I reviewed, the handler did not then require an authenticated administrator.

It did have another check, though. Before running anything, it called validateIdentifier(). That sounded worth a closer look.
casdoor-1-png.72

The hash anyone could calculate

The request had to include a timestamp in t and a hash in m. The server accepted timestamps within five minutes of its clock, in either direction, and calculated its own hash for comparison.

The important part of the handler source was:

Code:
version := "casbin-editor-v1"
rawString := fmt.Sprintf("%s|%s|%s", version, timestamp, paramString)

hasher := sha256.New()
hasher.Write([]byte(rawString))

The parameters were sorted by name, giving this input:

Code:
casbin-editor-v1|<timestamp>|args=<args>&language=<language>

Every part was available to the caller. The version string was public source code. The caller chose the timestamp, the language and the arguments. There was no secret key.

So a caller could choose a request and calculate exactly the hash the server expected.

SHA-256 was doing its job. Nothing here required breaking it. The mistake was treating a hash of public, caller-controlled data as proof that the caller should be allowed to run the operation. The time window did not change that. A new request could always use a fresh timestamp.

One flag reached a different path

After that check, Casdoor built the executable name from the language. For go, it looked for casbin-go-cli. The execution path included:

Code:
binaryName := fmt.Sprintf("casbin-%s-cli", language)
command := exec.Command(binaryName, processedArgs...)
outputBytes, err := command.CombinedOutput()

Go's exec.Command does not start a shell by default. Sending shell punctuation was not the trick here. The useful control was over arguments passed to an installed CLI.

Casbin uses a model and a policy to evaluate access decisions. Its CLI can load those from files. Casdoor tried to handle this by treating the values after -m and -p as text, writing that text to temporary files, and passing the temporary paths to the CLI.

For example, if the caller sent -m /etc/passwd, the wrapper would write the literal text /etc/passwd into a temporary file. The CLI would receive that temporary file, rather than the system file.

But the wrapper only recognised the exact short flags:

Code:
if (args[i] == "-m" || args[i] == "-p") && i+1 < len(args) {

The CLI accepted long flags as well. --model=/etc/passwd did not match that condition, so it passed through unchanged. The CLI then interpreted it as a path and opened /etc/passwd on the server.

That mismatch was enough. Casdoor and the CLI were interpreting the same input differently.
casdoor-2-png.73

The final part was the error handling. A Unix account file is not a valid Casbin model. When the model parser failed, its error included the content of the line it could not parse. Casdoor collected the process output and returned it in the API response.

The relevant error format looked like this:

Code:
parse the content error : line 1 , <file content> = ?

The request did not need a successful policy evaluation. The parser failure was what returned the data.

What the tests actually showed

On the official demo, I confirmed unauthenticated CLI execution and file content disclosure through this path, including content from /etc/passwd. The installed Go CLI reported version 1.12.0.

I also tested locally with the official all-in-one Docker image and a Go CLI installed for validation. The local results included content from /etc/shadow, information from /proc, and fragments of SQLite schema from /casdoor.db. Those local results should not be read as a claim that I extracted the demo server's database or password file, just saying lol.

MITRE's assignment email describes CVE-2026-94802 as allowing arbitrary code execution. My documented proof establishes unauthenticated execution of installed Casbin CLI commands with controlled arguments, plus file content disclosure. It does not establish an unrestricted operating-system shell out of the box.

The demonstrated result was already serious: an unauthenticated visitor could make an identity server run a tool against a file of their choosing and return content from it. Any broader compromise would depend on the available tools, process privileges, exposed data and how the identity service was connected to other applications.

The disclosure, and the changes I found later

I tried to disclose the issue directly to the maintainer by email and never received a response. I also requested a CVE from MITRE. The confirmation I received in September identifies the original request date as 21 March 2026.

On 24 March, I reported the issue to Switzerland's National Cyber Security Centre, known as BACS in German. Its vulnerability reporting process covers cases affecting Switzerland where contact with the supplier has not produced an adequate response.

After that, I checked the project from time to time. A change had appeared on 3 April under the title feat: improve permission command API. Commit ea2408a7 added this check:

Code:
if !conf.IsDemoMode() && !c.IsAdmin() {
    c.ResponseError(c.T("auth:Unauthorized operation"))
    return
}

It shipped in v2.379.0. Outside demo mode, the handler now required an admin. Demo mode was explicitly exempt, and the argument handling had not changed in that commit.

Finding that change was frustrating. The code had moved, but I still had no acknowledgement or explanation. I cannot confirm whether my report caused it. What I can confirm is that the change followed the disclosure and addressed anonymous access outside demo mode.

There were further relevant changes shortly before the CVE email arrived. On 25 September, 2b91d9e5 added handling for the long model and policy flags and rejected attached-value forms such as --model=.... Another change that day, 602eca9c, restricted the language parameter to lowercase letters. On 27 September, 884fcb4e tightened the non-demo check from admin to global admin.

So the patch history has more than one date. April brought an access restriction outside demo mode. September brought additional changes to argument handling and administrator scope.

If you run Casdoor

The affected range in MITRE's email starts at commit 7ccd8c4d from 15 November 2024 and runs through v2.363.0. That is the range stated in the assignment, not evidence that v2.364.0 fixed the issue. The April access restriction arrived in v2.379.0, and it left the demo-mode exception and argument handling described above.

At publication, v4.12.0 contains the later changes in its handler source. That is a source-level check of these changes, not an end-to-end retest or a claim that the entire release is free of security issues.

For an exposed installation, the practical steps are:

  • Upgrade to a current maintained release that includes the relevant changes.
  • Keep demo mode disabled on production deployments.
  • If the CLI endpoint is not needed, block external access to /api/run-casbin-command.
  • Review requests to that path, especially long model or policy flags containing server paths. If there is evidence of exposure, investigate which data was accessible and rotate affected secrets.

A request to the endpoint alone does not prove exploitation, since legitimate clients also use it. The arguments and returned errors provide more useful context.

What stays with me is how little was needed to cross the boundary. A public endpoint accepted a hash the caller could calculate. A wrapper missed a flag spelling that the CLI understood. Then an error message carried file content back out.

The CVE finally gives me the confirmation that this will finally be taken seriously and properly disclosed. The lack of communication, if not straight up ignorance bothered me alot tbh, with such a serious issue and then masking the fix as new feature. I understand for operational security it might make sense, but some transparency would definetly be preffered or expected!
 

Attachments

  • casdoor-1.png
    270.6 KB · Views: 2
  • casdoor-2.png
    243.5 KB · Views: 2
Back
Top