GroovyGate RCE: Root Access Despite a 403
As threat actors move toward automated zero-day discovery, the defensive side needs the same thing, agents that can hunt for vulnerabilities faster than a human team and find the bugs before someone else’s agent does. Vulnetic has joined that effort, running Sable against open-source infrastructure and reporting what we find back to maintainers before it becomes someone’s exploit chain. It’s a Y2K-scale problem, thousands of projects, a hard deadline nobody controls, and not nearly enough people looking. We’re one small team in that effort, but every bug we get patched before an attacker’s agent finds it is one less foothold in the software everyone runs.
OneDev is an all-in-one DevOps platform that bundles Git hosting, CI/CD, and issue tracking into one Java application. Sable, our adversarial AI, found a bug in it that lets any authenticated low privilege account execute code as root on the server.
Groovy is a JVM scripting language OneDev embeds so admins can customize behavior without recompiling the app, and it shows up in a lot of places, server-side scripts, commit-message-fix rules, external-issue transformers, default-value providers on issue fields, job variable interpolation. Any of those could be a way for a low-privileged account to reach code execution, if the authorization around them was loose enough.
Plan: (1) exhaustively enumerate in the v16.5.6 source every @Code usage, CodeValidator mode, GroovyUtils.compile/SimpleTemplateEngine.createTemplate call site, and every JAX-RS resource method taking @Valid beans with Groovy-compiling validators; (2) live-test each reachable candidate as outsider/member with the ASTTest payload watching for server-side markers; (3) record any newly reachable sink.
Sable started by grepping the cloned source for every file that touched Groovy and got back dozens of hits, Wicket pages for the admin UI, choice-provider classes for issue fields, and GroovyUtils.java itself, sitting in server-core/util with the actual compile and eval logic other classes call into.
Most of that list was a dead end fast, since the Wicket pages only render for logged-in administrators, and GroovyUtils.evalScriptByName() checks script.isAuthorized() before running anything, so a saved script still needs an admin's blessing to execute.
Groovy wasn’t the only thing on the list that day. Sable was also running a broad matrix across every settings endpoint, POSTing valid and deliberately invalid payloads as each role, owner, maintainer, member, outsider, admin, and diffing what came back. Most rows in that matrix were boring, 403 for non-admins, 200 for admin. One wasn't.
The tail shows consistent 403s for non-admins (and a 400 validation error on issue POST, meaning it was rejected at validation after authz… actually 400 means bean validation fired — need to see whether that was before or after the auth check, but the status isn’t 200 so no save).
That parenthetical hedge was the whole ballgame. A 400 from an admin-only endpoint means something read the body before deciding whether to reject the caller. Sable went looking for the same signal somewhere more useful than a hardcoded validation rule and pointed a deliberately broken Groovy template at the notification-template endpoint as outsider.
Groovy compiler errors come from a compiler. An account with no admin rights had just reached one, behind an endpoint that should have stopped it cold.
That status code sent Sable into SettingResource.java, then into the bean it validates. The administrator check sat in the first line of both methods.
@Valid on the parameter means JAX-RS runs bean validation before either method body executes, isAdministrator() included. Whatever validation touches, it touches under the identity of whoever sent the request.
That sent Sable looking at what validation actually does to these fields. It flagged the sink before it knew whether it was exploitable.
CodeValidator uses SimpleTemplateEngine, that's a validation-time sink worth examining, since @Code-annotated fields exist in many user-reachable beans.
GroovyScript.content and all fifteen fields on EmailTemplates carry a @Code constraint, and CodeValidator compiles them.
GroovyUtils.compile() hands the string to GroovyClassLoader.parseClass(). Compiling Groovy normally just checks syntax. The bundled runtime ships groovy.transform.ASTTest, an annotation built for the compiler's own test suite, whose closure runs as an AST transformation the instant the class compiles, before anyone calls the resulting script. Validating a string and running it had just become the same operation.
Sable built a payload around that closure and sent it as outsider.
The HTTP response looked unremarkable, 403, Not authorized, exactly what a non-admin should see. A shell check on the server told a different story.
The marker didn’t exist before the request. It existed after, owned by root. outsider never touched the filesystem. SimpleTemplateEngine did, on its way to deciding whether the template was well-formed enough to save.
One working payload confirmed the mechanism. Sable still needed to know how far it reached, so it repeated the exploit against the second endpoint, POST /~api/settings/groovy-scripts, which validates through the separate Code.GROOVY path and GroovyUtils.compile() instead of the template engine.
Same result on a different code path, 403 on the wire, a second root-owned marker file on disk.
Sable then wrote down the general rule and went to go test it against everything else in the settings API.
Any endpoint where bean validation compiles attacker-influenced Groovy before authorization runs is the same low-auth-to-root RCE.
It swept every other @Valid-annotated settings method, system, build, workspace, issue, project, pull-request, security, SSH, job executors, plus buildspec conditions and script references. All of those validate before their admin check runs too, but none of them contain a Groovy-compiling field. Every one of them came back a plain 403 or 400, with zero callbacks. The vulnerable surface is exactly the two endpoints above.
Nothing persisted through any of it. GET /~api/settings/groovy-scripts stayed [] throughout, and the notification templates matched the pre-test backup byte for byte. The exploit lives entirely inside a request the server thinks it rejected.
ASTTest and GroovyClassLoader.parseClass() are old parts of the Groovy compiler, present for years. What made them reachable here was a mixup between two different questions, is this input well-formed, and is this caller allowed to send it. OneDev answered the first one with code that runs immediately, under whatever privilege the caller already has, before the second question ever gets asked. Getting the validator to run malicious code is enough on its own, whether or not the request that carried it ever gets approved.
The question worth asking about your own admin-only endpoints is what runs before the authorization check gets a chance to decide anything.
Find and patch critical zero days in your environment: vulnetic.ai