-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## Two endpoints. Same JWT. Different gates.\n\n`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.\n\n`/sys/login` is the web login. It runs seven steps. The original author numbered them in comments:\n\n```java\n@RequestMapping(value = \"/login\", method = RequestMethod.POST)\npublic Result login(@RequestBody SysLoginModel sysLoginModel, HttpServletRequest request){\n Result result = new Result<>();\n String username = sysLoginModel.getUsername();\n String password = AesEncryptUtil.resolvePassword(sysLoginModel.getPassword());\n\n //step.1 登录失败超出次数5次锁定用户10分钟\n if(isLoginFailOvertimes(username)){\n return result.error500(\"该用户登录失败次数过多,请于10分钟后再次登录!\");\n }\n\n // step.2 验证码check\n String realKey = validateCaptcha(sysLoginModel, result);\n if (realKey == null) {\n return result;\n }\n\n // step.3 校验用户是否存在且有效\n // step.4 校验用户名或密码是否正确\n // step.5 登录成功获取用户信息\n // step.6 登录成功删除验证码\n // step.7 记录用户登录日志\n```\n\nStep 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.\n\n`/sys/mLogin` is the \"app login.\" It runs the same overall shape, re-numbered:\n\n```java\n@RequestMapping(value = \"/mLogin\", method = RequestMethod.POST)\npublic Result mLogin(@RequestBody SysLoginModel sysLoginModel, HttpServletRequest request) throws Exception {\n Result result = new Result();\n String username = sysLoginModel.getUsername();\n String password = AesEncryptUtil.resolvePassword(sysLoginModel.getPassword());\n\n // 1.平台用户登录失败锁定用户\n if(isLoginFailOvertimes(username)){\n return result.error500(\"该用户登录失败次数过多,请于10分钟后再次登录!\");\n }\n // 2.校验用户是否有效\n SysUser sysUser = sysUserService.getUserByName(username);\n result = sysUserService.checkUserIsEffective(sysUser);\n if(!result.isSuccess()) { return result; }\n\n // 3.校验用户名或密码是否正确\n String userpassword = PasswordUtil.encrypt(username, password, sysUser.getSalt());\n String syspassword = sysUser.getPassword();\n if (!syspassword.equals(userpassword)) {\n addLoginFailOvertimes(username);\n result.error500(\"用户名或密码错误\");\n return result;\n }\n\n // 4.设置登录部门\n // 5. 设置登录租户\n // 6. 生成token,并设置超时时间\n String token = JwtUtil.sign(username, syspassword, CommonConstant.CLIENT_TYPE_APP);\n // 7.是否允许同一账号多地同时登录\n // 8.登录成功记录日志\n```\n\nEight 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.\n\nThe 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.\n\n## What the PoC sends\n\n`HORKimhab/CVE-2026-8196` is twenty-four lines of meaningful Python. Its core function:\n\n```python\ndef mlogin(self, username: str, password: str) -> Dict:\n \"\"\"Bypass captcha using mLogin endpoint\"\"\"\n payload = {\n \"username\": username,\n \"password\": password\n # No captcha field needed!\n }\n response = self.session.post(self.mlogin_url, json=payload)\n return response.json()\n```\n\nThe PoC's docstring is the diff in prose:\n\n```\n/sys/login -> enforces captcha\n/sys/mLogin -> no captcha, same credentials, returns full JWT token\nNo rate limiting or account lockout (as shown in the original report)\nEnables efficient brute-force / credential stuffing\n```\n\nReproducing it against a stock JeecgBoot 3.9.1 install is one POST:\n\n```bash\ncurl -sS -X POST 'http://target:8080/jeecg-boot/sys/mLogin' \\\n -H 'Content-Type: application/json' \\\n -d '{\"username\":\"admin\",\"password\":\"123456\"}'\n```\n\nIf 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.\n\nThe 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.\n\nThe 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.\n\n## What `validateCaptcha` does\n\nTwo hundred and seventy-eight lines below `mLogin`, the same controller declares:\n\n```java\nprivate String validateCaptcha(SysLoginModel sysLoginModel, Result result) {\n // 判断是否启用登录验证码校验\n if (jeecgBaseConfig.getFirewall() != null && Boolean.FALSE.equals(jeecgBaseConfig.getFirewall().getEnableLoginCaptcha())) {\n log.warn(\"关闭了登录验证码校验,跳过验证码校验!\");\n return \"LoginWithoutVerifyCode\";\n }\n\n String captcha = sysLoginModel.getCaptcha();\n if (captcha == null) {\n result.error500(\"验证码无效\");\n return null;\n }\n String lowerCaseCaptcha = captcha.toLowerCase();\n String keyPrefix = Md5Util.md5Encode(sysLoginModel.getCheckKey() + jeecgBaseConfig.getSignatureSecret(), \"utf-8\");\n String realKey = keyPrefix + lowerCaseCaptcha;\n Object checkCode = redisUtil.get(realKey);\n if (checkCode == null || !checkCode.toString().equals(lowerCaseCaptcha)) {\n result.error500(\"验证码错误\");\n result.setCode(HttpStatus.PRECONDITION_FAILED.value());\n return null;\n }\n return realKey;\n}\n```\n\nTwenty-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.\n\nThe 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);`.\n\n## The captcha is the rate-limiter, not the password check\n\nThe 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.\n\nCAPTCHA'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.\n\n`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.\n\nCVSS 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.\n\nThe PoC author understood this. The docstring lists the steps the operator will run, not the steps a researcher confirming a finding will run.\n\n## Same class. Sibling methods. One calls the gate.\n\nThis is the [Parallel Implementation Gap](https://nefariousplan.com/patterns/parallel-implementation-gap) pattern. The catalog has named the shape eight times. Astro [shipped two `/_image` endpoints](https://nefariousplan.com/posts/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.\n\nJeecgBoot'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.\n\nThe 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.\n\nThe 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.\n\nPoC: [HORKimhab/CVE-2026-8196](https://github.com/HORKimhab/CVE-2026-8196)","closing_line":"The patch will be one line. The convention has been local to this file the whole time.","hook_md":"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.\"","post_id":302,"slug":"jeecgboot-mlogin-validatecaptcha-sibling-method","title":"CVE-2026-8196: validateCaptcha Is a Sibling Method. mLogin Does Not Call It.","type":"initial","unreadable_sentence":"`mLogin` does not bypass the captcha. `mLogin` was written without it."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagoYoQAKCRDeZjl4jgkQ JjlOAP4/xkKMjk8V19kj3KxomppDEtonWipxpUP94wOVE9dpeAEAnKdrpEI+BNQs jOUiYsYWOSQurFhLrItXHIv2vlHM5gM= =Kx/4 -----END PGP SIGNATURE-----