//nefariousplan

CVE-2026-8196: validateCaptcha Is a Sibling Method. mLogin Does Not Call It.

pattern

cve

proof of concept

CVE-2026-8196 is a captcha bypass in JeecgBoot 3.9.1. The captcha implementation is a private method called validateCaptcha. It lives in LoginController.java. The endpoint named in the CVE, /sys/mLogin, is a method on the same Java class as validateCaptcha. They are siblings. mLogin does not call its sibling. The CVE record's description ends with the sentence "The vendor was contacted early about this disclosure but did not respond in any way."

Two endpoints. Same JWT. Different gates.

jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java declares two HTTP routes that accept a username and a password and return a JWT issued for that user. The first is annotated @RequestMapping(value = "/login", method = RequestMethod.POST) at line 84. The second is annotated @RequestMapping(value = "/mLogin", method = RequestMethod.POST) at line 650. They sit five hundred and sixty-six lines apart in the same file.

/sys/login is the web login. It runs seven steps. The original author numbered them in comments:

@RequestMapping(value = "/login", method = RequestMethod.POST)
public Result<JSONObject> login(@RequestBody SysLoginModel sysLoginModel, HttpServletRequest request){
    Result<JSONObject> result = new Result<>();
    String username = sysLoginModel.getUsername();
    String password  = AesEncryptUtil.resolvePassword(sysLoginModel.getPassword());

    //step.1 登录失败超出次数5次锁定用户10分钟
    if(isLoginFailOvertimes(username)){
        return result.error500("该用户登录失败次数过多,请于10分钟后再次登录!");
    }

    // step.2 验证码check
    String realKey = validateCaptcha(sysLoginModel, result);
    if (realKey == null) {
        return result;
    }

    // step.3 校验用户是否存在且有效
    // step.4 校验用户名或密码是否正确
    // step.5 登录成功获取用户信息
    // step.6 登录成功删除验证码
    // step.7 记录用户登录日志

Step 2 is the captcha check. The handler reads the captcha and checkKey fields off the request body, hashes the checkKey against the configured signature secret, looks up the hashed key in Redis, and rejects the request with HTTP 412 if the captcha is missing or wrong. If the captcha clears, control proceeds to the password check at step 4.

/sys/mLogin is the "app login." It runs the same overall shape, re-numbered:

@RequestMapping(value = "/mLogin", method = RequestMethod.POST)
public Result<JSONObject> mLogin(@RequestBody SysLoginModel sysLoginModel, HttpServletRequest request) throws Exception {
    Result<JSONObject> result = new Result<JSONObject>();
    String username = sysLoginModel.getUsername();
    String password  = AesEncryptUtil.resolvePassword(sysLoginModel.getPassword());

    // 1.平台用户登录失败锁定用户
    if(isLoginFailOvertimes(username)){
        return result.error500("该用户登录失败次数过多,请于10分钟后再次登录!");
    }
    // 2.校验用户是否有效
    SysUser sysUser = sysUserService.getUserByName(username);
    result = sysUserService.checkUserIsEffective(sysUser);
    if(!result.isSuccess()) { return result; }

    // 3.校验用户名或密码是否正确
    String userpassword = PasswordUtil.encrypt(username, password, sysUser.getSalt());
    String syspassword = sysUser.getPassword();
    if (!syspassword.equals(userpassword)) {
        addLoginFailOvertimes(username);
        result.error500("用户名或密码错误");
        return result;
    }

    // 4.设置登录部门
    // 5. 设置登录租户
    // 6. 生成token,并设置超时时间
    String token = JwtUtil.sign(username, syspassword, CommonConstant.CLIENT_TYPE_APP);
    // 7.是否允许同一账号多地同时登录
    // 8.登录成功记录日志

Eight steps. None of them are a captcha check. The handler reads the same SysLoginModel payload, runs the same per-username fail-count lockout, runs the same password check at step 3, and at step 6 calls JwtUtil.sign(username, syspassword, CommonConstant.CLIENT_TYPE_APP) to return a token.

The JWT that mLogin returns is signed with the same secret as the JWT that login returns. The two tokens carry different clientType claims, PC versus APP. Apache Shiro and the JWT filter that JeecgBoot composes in front of its REST controllers do not consult clientType when deciding whether to accept the token for a given request. A token issued by mLogin authenticates the same set of API calls a token issued by login does.

What the PoC sends

HORKimhab/CVE-2026-8196 is twenty-four lines of meaningful Python. Its core function:

def mlogin(self, username: str, password: str) -> Dict:
    """Bypass captcha using mLogin endpoint"""
    payload = {
        "username": username,
        "password": password
        # No captcha field needed!
    }
    response = self.session.post(self.mlogin_url, json=payload)
    return response.json()

The PoC's docstring is the diff in prose:

/sys/login   -> enforces captcha
/sys/mLogin  -> no captcha, same credentials, returns full JWT token
No rate limiting or account lockout (as shown in the original report)
Enables efficient brute-force / credential stuffing

Reproducing it against a stock JeecgBoot 3.9.1 install is one POST:

curl -sS -X POST 'http://target:8080/jeecg-boot/sys/mLogin' \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"123456"}'

If the credentials clear, the response body carries result.token, a JWT signed by the server's HS256 secret. That token is acceptable as X-Access-Token on every authenticated route the controller chain protects.

The PoC's claim that mLogin has "no rate limiting or account lockout" is technically wrong. mLogin calls isLoginFailOvertimes() and addLoginFailOvertimes(), the same per-username 5-strike lockout login uses. Where the PoC is right is the more important claim: with the captcha gone, an attacker performing credential stuffing across many distinct usernames, one password per username, never trips the per-username counter and runs at network speed.

The PoC ships a brute_force() method that hammers a single username at 100 ms intervals up to 50 attempts. That demo trips the 5-strike lockout. The docstring tells the operator what the real shape is. The shape is credential stuffing, not single-user brute force.

What validateCaptcha does

Two hundred and seventy-eight lines below mLogin, the same controller declares:

private String validateCaptcha(SysLoginModel sysLoginModel, Result<JSONObject> result) {
    // 判断是否启用登录验证码校验
    if (jeecgBaseConfig.getFirewall() != null && Boolean.FALSE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha())) {
        log.warn("关闭了登录验证码校验,跳过验证码校验!");
        return "LoginWithoutVerifyCode";
    }

    String captcha = sysLoginModel.getCaptcha();
    if (captcha == null) {
        result.error500("验证码无效");
        return null;
    }
    String lowerCaseCaptcha = captcha.toLowerCase();
    String keyPrefix = Md5Util.md5Encode(sysLoginModel.getCheckKey() + jeecgBaseConfig.getSignatureSecret(), "utf-8");
    String realKey = keyPrefix + lowerCaseCaptcha;
    Object checkCode = redisUtil.get(realKey);
    if (checkCode == null || !checkCode.toString().equals(lowerCaseCaptcha)) {
        result.error500("验证码错误");
        result.setCode(HttpStatus.PRECONDITION_FAILED.value());
        return null;
    }
    return realKey;
}

Twenty-four lines. Reads captcha and checkKey off the SysLoginModel (the same payload object mLogin reads). Hashes the checkKey, looks up the hash in Redis, returns the Redis key on success or null on failure. Callers distinguish "captcha cleared" from "captcha failed" by null versus not-null.

The method modifier is private. The method belongs to LoginController. Every other method on LoginController can invoke it without an import, without a new constructor, without a Spring wire. mLogin() and validateCaptcha() are methods on the same instance of the same class. To call validateCaptcha from mLogin, the only step a developer needs to take is to type validateCaptcha(sysLoginModel, result) between the existing comment // 2.校验用户是否有效 and the line that begins SysUser sysUser = sysUserService.getUserByName(username);.

The captcha is the rate-limiter, not the password check

The CVE record describes CVE-2026-8196 as an "authorization bypass." That phrasing is mechanically wrong. mLogin still requires a correct username and password. The password check at line 674 is identical to the password check at line 115. The bug is not that an attacker can log in without credentials. The bug is that an attacker can submit credentials at the speed of a network connection.

CAPTCHA's job in the JeecgBoot login flow is not to authenticate the user. CAPTCHA is the application's rate-limiter for the password oracle. The per-username 5-strike lockout caps how many guesses an attacker can make against one account. CAPTCHA caps how many distinct accounts the attacker can probe in a session. With CAPTCHA present, a credential-stuffing campaign, N attacker-controlled (username, password) pairs from a credential dump, has to clear a fresh CAPTCHA per request. With CAPTCHA absent, the campaign runs in a worker pool.

mLogin does not bypass the captcha. mLogin was written without it. The captcha was never in mLogin's control flow. There is no intercept layer, no annotation, no Shiro filter that consults a captcha cookie. There is the request, and there is the password check.

CVSS 3.7 (the rating the CVE record carries) prices this as low because the assessor scored C:L/I:N/A:N against a single successful exploitation step. That model assumes one attempt, one credential, one outcome. The shape of the actual primitive is N attempts, N credentials, one of N outcomes succeeds, and on success the attacker holds an admin-equivalent JWT against an internet-reachable JeecgBoot administration UI. The score reflects the math of the password check. It does not reflect the math of the credential-stuffing pipeline mLogin enables.

The PoC author understood this. The docstring lists the steps the operator will run, not the steps a researcher confirming a finding will run.

Same class. Sibling methods. One calls the gate.

This is the Parallel Implementation Gap pattern. The catalog has named the shape eight times. Astro shipped two /_image endpoints across packages; the Cloudflare adapter never imported isRemoteAllowed. mcp-atlassian guarded its download open() calls and forgot its upload open() calls in the same file. MLflow's basic-auth wrote a Flask before_request hook and a FastAPI middleware in the same factory; only the Flask gate was on the FastAPI allowlist. Vite's /@fs middleware called isServerAccessDeniedForTransform; the WebSocket fetchModule handler called the same loader without the same gate. Axios's maxContentLength lived inside the buffered branch's chunk counter; the streaming branch had no counter and no limit.

JeecgBoot's variant is closer to copy than any of these. The gate is not in another package. It is not in another file. It is not in another runtime. It is not in a sibling module. It is a private method on the same Java class. The endpoint named in the CVE is a method on that same Java class. They share the same controller instance, the same dependency injections, the same logger field. The author of mLogin did not need to import anything. They had login() on screen above as a reference. They copied steps 1, 3, 4, 5, 7, 8. They did not copy step 2.

The CAPTCHA convention is documented in the file. login() invokes validateCaptcha at step 2. phoneLogin() validates an SMS code at line 447 as its functional equivalent. mLogin() invokes nothing. The convention is local to LoginController.java. The convention is in the same class as the bug. The convention was never enforced as a contract, because the contract was the developer's habit of copying their own code carefully, and the developer who added the mobile login copied carelessly.

The CVE description ends with a sentence that says the vendor was contacted early about the disclosure and did not respond. The patch, if and when it ships, will be one line in mLogin(). The vendor's silence is on the record. The one line is on the record.

PoC: HORKimhab/CVE-2026-8196

The patch will be one line. The convention has been local to this file the whole time.