Maccms10 Password Reset Code Brute Force Account Takeover
# MacCMS v10 member password recovery accepts a brute-forceable six-digit code, enabling unauthenticated account takeover
, the recovery design is long-standing so earlier releases are likely affected
**Class:** CWE-640 Weak Password Recovery Mechanism, CWE-307 Improper Restriction of Excessive Authentication Attempts, CWE-770 Allocation of Resources Without Limits or Throttling
MacCMS v10 lets a member recover a forgotten password with a six-digit numeric code sent by email or SMS. The completion route `user/findpass_reset` is reachable without any login, it is listed in the controller guest whitelist. The server verifies the submitted code with a single database equality query that matches on the value, the code, the type, the unused status, and a short time window. There is no failed-attempt counter, no per- or per-IP lockout, no captcha, and no backoff on the verification route. The code space is only 1,000,000 values. An unauthenticated attacker who knows or enumerates a target member email or phone can brute-force the active code inside its validity window and overwrite that member's password. The trust boundary crossed is between two different members, the attacker ends up controlling a victim account they never authenticated to. Member values are enumerable by default through the unauthenticated user list API, which removes the only soft precondition.
The weakness is a recovery code with too little entropy combined with a verification path that applies no rate limiting or lockout. The code trace below walks the route registration, the code generation, the verification query, and the password overwrite.
### Entry point: the recovery completion route is unauthenticated
`application/index/controller/User.php` gates most member actions behind a login check in the constructor, but it keeps a whitelist of actions that run for guests. `findpass_reset` is on that whitelist (line 16).
// application/index/controller/User.php:16
$guestAllowedActions = ['login', 'logout', 'ajax_login', 'reg', 'findpass', 'findpass_msg', 'findpass_reset', 'reg_msg', 'oauth', 'logincallback', 'visit', 'index', 'ajax_upgrade', 'ajax_buy_popedom'];
The action itself reads raw request input and forwards it straight to the model (line 397).
// application/index/controller/User.php:397
$res = model('User')->findpass_reset($param);
No captcha, token, or throttle sits in front of this action.
### The recovery code is a six-digit number
The code is created in `send_msg()` in `application/common/model/User.php` (line 1089).
// application/common/model/User.php:1089
`mac_get_rndstr(6,'num')` returns a six-character numeric string. The value space is 000000 through 999999, which is 1,000,000 candidates. The row that stores it is written with the caller user_id, which is 0 for an unauthenticated request.
// application/common/model/User.php, inside send_msg()
$data['user_id'] = intval($GLOBALS['user']['user_id']); // 0 for a guest
$data['msg_type'] = $param['type']; // 2 = findpass
$data['msg_to'] = $to; // victim email or phone
$data['msg_code'] = $code; // the six-digit code
### Verification matches on the code with no attempt limit
`findpass_reset()` in the model (line 1143) calls `check_msg()` to validate the submitted code before it touches the password. `check_msg()` (line 1036) builds the lookup.
// application/common/model/User.php:1036 check_msg()
$where['user_id'] = intval($GLOBALS['user']['user_id']); // line 1047, equals 0 for a guest
$where['msg_time'] = ['gt',$stime]; // default window: 5 minutes
$where['msg_code'] = ['eq',$param['code']]; // line 1051, attacker-supplied guess
$where['msg_type'] = ['eq', $param['type'] ]; // 2 = findpass
$where['msg_to'] = ['eq', $param['to'] ]; // victim
$where['msg_status']= ['eq', 0]; // unused
return ['code'=>9002,'msg'=>lang('model/user/msg_not_found')];
return ['code'=>1,'msg'=>'ok','msg_id'=>$res['info']['msg_id']];
The user_id term is 0 on both write and read, so it adds no secret. The only values an attacker does not already control are the code and the time window. Each call to `check_msg()` is one plain equality query. There is no counter on the msg row for failed guesses, no per- or per-IP lockout, no incremental delay, and no cap on verification calls. The lone constraint is the 5 minute default validity window, which is also admin-configurable to a longer value for email.
### The password is overwritten for the resolved victim
After `check_msg()` returns success, `findpass_reset()` (line 1143) resolves the victim account from the , consumes the code atomically, and overwrites the password.
// application/common/model/User.php:1143 findpass_reset() (key section)
return ['code'=>$res['code'],'msg'=>$res['msg']];
// resolve the target account from the submitted
$where['user_email'] = $to; // or $where['user_phone'] = $to for the phone branch
return ['code' => 2006, 'msg' => lang('model/user/email_err')];
$consumed = Db::name('msg')->where(['msg_id'=>$msg_id,'msg_status'=>0])->update(['msg_status'=>1]);
return ['code'=>9002,'msg'=>lang('model/user/msg_not_found')];
$update['user_pwd'] = mac_hash_password_for_column($password_raw, 'user', 'user_pwd');
$res = $this->where($where)->update($update);
The atomic consume prevents replay of a code that already succeeded, but it does not stop an attacker from guessing the still-unused code. Once a guess matches, the victim password is replaced with the attacker-chosen value and the takeover is complete.
### Why the enumeration precondition is weak
The attacker needs a victim email or phone. MacCMS exposes `api.php/user/get_list` with no authentication, returning `user_phone` for every registered member, tracked publicly as CVE-2026-75465. That endpoint turns the into a known quantity for every account, so the recovery brute force has no real precondition beyond reachability of the route.
participant A as Unauthenticated attacker
participant R as Route user findpass_reset
A->>R: POST victim with guessed six digit code and new password
M->>DB: check_msg equality query on code and , user_id zero
M-->>A: code 9002 recovery code not found
Note over A: repeat guesses across one million values within the window, no lockout
A->>R: POST victim with the correct code and new password
M->>DB: consume code then update user_pwd for the victim
Note over A: attacker logs in with the new password, account taken over
The request bodies below are illustrative and were not executed against any live deployment. They show the parameter shapes derived from the source.
Prerequisites: a reachable MacCMS v10 instance with the member module and email or SMS recovery enabled, and one victim email or phone. The victim can be read from the unauthenticated user list API where that endpoint is present.
1. Pick a victim . Request example, unexecuted:
`GET /api.php/user/get_list?limit=50&offset=0`
The response lists registered members including `user_phone`. State change: none, this is a read. The attacker now holds a victim phone or email.
2. Trigger a recovery code for the victim so an unused code row exists. The recovery send path writes a `msg` row with `msg_type` 2, `msg_status` 0, `msg_to` set to the victim , and a six-digit `msg_code`. Request example, unexecuted:
`POST /index.php/user/findpass_msg` with body `ac=phone&to=1 &type=2`
Response is a JSON success. State change: one unused recovery row now exists in the `msg` table bound to the victim , valid for the configured window.
3. Brute-force the code against the completion route. Each attempt is one POST. Request example, unexecuted:
`POST /index.php/user/findpass_reset` with body `ac=phone&to=1 &code=000000&user_pwd=AttackerPass1&user_pwd2=AttackerPass1`
For a wrong guess the response is JSON `code 9002` recovery code not found. State change: none, the row stays unused, and no failed-attempt counter is incremented. The attacker iterates `code` across 000000 to 999999 within the validity window.
4. Land the correct code. When `code` matches the stored value the query in `check_msg()` returns the row. State change: the recovery row flips to `msg_status` 1 and the victim `user_pwd` is replaced with the hash of the attacker-chosen password. Response is JSON `code 1` password reset ok.
5. Authenticate as the victim. The attacker logs in through the normal member login with the victim or user name and the new password. State change: the attacker now holds a valid member session for the victim account.
Runtime validation was not performed. No request was sent to any live deployment, no account or code was brute-forced, and no PoC was executed. The finding was confirmed by reading the pinned release source at tag v2026.1000.4056.
- `application/index/controller/User.php` line 16, `findpass_reset` is in `$guestAllowedActions`, so the route runs for unauthenticated callers.
- `application/index/controller/User.php` line 397, `findpass_reset()` forwards raw input to `model('User')->findpass_reset()`.
- `application/common/model/User.php` line 1089, the code is `mac_get_rndstr(6,'num')`, six numeric digits, 1,000,000 values.
- `application/common/model/User.php` lines 1036 to 1051, `check_msg()` validates with one equality query on `msg_code`, `msg_to`, `msg_type`, `msg_status`, and a time window, with the user_id term equal to 0 for a guest.
- `application/common/model/User.php` line 1143, `findpass_reset()` resolves the victim by `user_email` or `user_phone` and updates `user_pwd` after `check_msg()` returns success.
Statically confirmed: the route is unauthenticated, the code space is 1,000,000, the verification query carries no attempt counter or lockout, and a matching guess overwrites the victim password. Not confirmed at runtime: the achievable guess rate against a production host and the real-world effect of the configured window on completion time.
An unauthenticated attacker can take over a different member's account by brute-forcing the six-digit recovery code and resetting that member's password.
- Code keyspace. 1,000,000 candidates. With no lockout, backoff, or captcha on the verification route, the attacker is limited only by request throughput and the validity window. The default window is 5 minutes, which is tight for a single host but the window is admin-configurable to a larger value for email recovery, and distributed or high-throughput clients shrink the time needed.
- Victim . The attacker needs the victim email or phone. MacCMS exposes an unauthenticated user list API that returns member phone numbers, so this precondition is commonly satisfied by default. Even without that endpoint a single known member is enough.
- Recovery enabled. Email or SMS recovery must be configured for the code path to produce a row. This is a standard member feature.
- No admin panel compromise. The admin login uses a separate model and is not reached through this member recovery flow.
- No claim that a single low-rate client always succeeds inside a 5 minute window. Success depends on throughput against the keyspace and the configured window.
- The unauthenticated user list exposure is a separate public issue, it is referenced only as an enumeration precondition and is not claimed here.
**Where exactly:** `application/common/model/User.php` lines 1036 to 1051 (`check_msg`) and line 1089 (`send_msg` code generation), plus the route exposure in `application/index/controller/User.php`.
+ // high-entropy code, at least 8 to 10 digits or an alphanumeric token
$where['user_id'] = intval($GLOBALS['user']['user_id']);
$where['msg_code'] = ['eq',$param['code']];
$where['msg_type'] = ['eq', $param['type'] ];
$where['msg_to'] = ['eq', $param['to'] ];
+ // enforce an attempt counter and lockout per before the lookup
+ if (model('msg')->attempt_count($param['to'], $param['type']) >= 5) {
+ return ['code'=>9003,'msg'=>lang('too_many_attempts')];
+ model('msg')->attempt_inc($param['to'], $param['type']);
return ['code'=>9002,'msg'=>lang('model/user/msg_not_found')];
Short term: increase the code length so the keyspace is far larger than any feasible number of guesses inside the window, and shorten the validity window to a few minutes.
Medium term: add a persistent failed-attempt counter keyed on the and the recovery type, lock the recovery for that after a small number of wrong guesses, and invalidate the active code on lockout. Add a per-IP cap on calls to `findpass_reset` and `findpass_msg`.
Defense in depth: place a captcha on the recovery completion route, compare the code in constant time, make the code strictly single use even on a near-match, and consider a signed high-entropy recovery link instead of a short numeric code.
The practical fix is a longer high-entropy code, a persistent per- attempt counter with lockout, a short single-use window, and a per-IP rate limit on the recovery routes.
- No public advisory for this recovery-code brute force at time of writing.
- Separate known MacCMS issues referenced only for context: unauthenticated user list API (CVE-2026-75465), member order detail IDOR (CVE-2026-4563), admin SQL injection (CVE-2022-35148).
- Pinned source, route whitelist and action:
- Pinned source, recovery model logic:
- Pinned source, API recovery entry:
- Release tag reviewed:
Reported by hieuPenguinnn, [github.com/hieuPenguinnn](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
