| Descrizione | ruoyi-vue-pro (Yudao) does not limit failed SMS verification-code attempts. SmsCodeServiceImpl.validateSmsCode0() (yudao-module-system/.../service/sms/SmsCodeServiceImpl.java, lines 93-110) only checks, in order: (1) does a record exist for this mobile+code+scene, (2) is it expired, (3) is it already used. There is no failed-attempt counter, no lockout, no delay and no CAPTCHA - and the system_sms_code table has no column for such a counter (columns are id, mobile, code, create_ip, scene, today_index, used, used_time, used_ip, creator, create_time, updater, update_time, deleted, tenant_id; today_index counts SENT codes only). Worse, the lookup matches on the code value itself (SmsCodeMapper.selectLastByMobile: selectOne(... eq mobile, eqIfPresent scene, eqIfPresent code ...)), so a wrong guess never touches the row that holds the valid code: failures neither increment anything nor invalidate the valid code. Only a successful verification consumes the record (SmsCodeServiceImpl.useSmsCode(), lines 79-86). Configuration shows the asymmetry (yudao-server/src/main/resources/application.yaml lines 344-349): expire-times 10m, send-frequency 1m, send-maximum-quantity-per-day 10, i.e. the SENDING side is limited but the VERIFICATION side is not.
Two unauthenticated endpoints consume this check: POST /admin-api/system/auth/sms-login (a correct code issues an access token for the account owning that mobile number - login without a password) and POST /admin-api/system/auth/reset-password (a correct code allows resetting the password). Sending a code for the LOGIN scene (scene=21) requires no CAPTCHA at all: AdminAuthServiceImpl.sendSmsCode() (lines 117-131) validates the CAPTCHA only when scene equals SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD (scene=23). AdminAuthServiceImpl.smsLogin() (lines 135-147) then calls useSmsCode() and createTokenAfterLoginSuccess().
Reproduction - the lab instance was deliberately hardened: started with --yudao.captcha.enable=true --yudao.sms-code.begin-code=1000 --yudao.sms-code.end-code=9999, i.e. CAPTCHA enforced AND a randomized 4-digit code range (so this is NOT the separate issue of the shipped constant 9999). Only two headers are used: Content-Type: application/json and tenant-id: 1; no Authorization header at any step. (1) Control: POST /admin-api/system/auth/send-sms-code with {"mobile":"18818260272","scene":23} returns {"code":400,"msg":"请求参数不正确:验证码不能为空"} - the CAPTCHA is genuinely enforced; the identical call with "scene":21 is accepted without any CAPTCHA. (2) Request a login code for the mobile of the built-in admin account: {"mobile":"18818260272","scene":21} -> {"code":0,"data":true}. 18818260272 is published in the shipped SQL seed (sql/mysql/ruoyi-vue-pro.sql), so no information gathering is required. (3) Brute force POST /admin-api/system/auth/sms-login with {"mobile":"18818260272","code":"§0000§"} over 0000-9999 using Burp Intruder: the successful response is easy to distinguish (638 bytes response length versus 474 bytes for rejected codes) and the hit came at payload 7504 - about 7.5k guesses in one run, with no HTTP 429 and no lockout at any point. The response was {"code":0,"msg":"","data":{"userId":1,"accessToken":"...","refreshToken":"...","expiresTime":...}}. (4) Identity self-proof: GET /admin-api/system/auth/get-permission-info with Authorization: Bearer <token from step 3> returned {"code":0,...,"user":{"id":1,"nickname":"芋道源码","username":"admin",...},"roles":["common","super_admin"],"permissions":[...]} - the brute-forced token belongs to the SUPER ADMINISTRATOR. Additional controls: 30 consecutive verification requests in about one second all returned HTTP 200 without any 429 or lockout; a wrong code returns {"code":1002014000,"msg":"验证码不存在"} and afterwards the correct code still works (failures neither lock the account nor invalidate the valid code); with a plain script (10 threads against the same lab host) the same attack recovered the code in 2045 attempts / 4.69 s, 6894 / 16.81 s and 8101 / 18.90 s (410-436 req/s), each ending in a successful login as admin, and the corresponding system_sms_code row showed used=1 afterwards. Note that a sending-side control does exist and works: two codes to one mobile within a minute return {"code":1002014005,"msg":"短信发送过于频繁"}.
Impact: an unauthenticated attacker who knows a target account's mobile number can obtain that account's access token without any password (full account takeover); for the shipped admin account the mobile number is public in the seed SQL. The same missing limit also applies to /auth/reset-password, allowing the password itself to be changed. The CAPTCHA is not part of this attack chain. Remediation: add a per mobile+scene failed-attempt counter with lockout (invalidate the code and block further verification for e.g. 15 minutes after 3-5 failures), count every failed attempt, consider invalidating the code after repeated failures, provide a CAPTCHA-enable option for every send-sms-code scene (scene=21 currently has none, while a default of off is acceptable), stop reusing one verification mechanism for both password reset and login, and throttle/alert on abnormal verification volume per mobile and per IP.
|
|---|