/generate
@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
# 返回base64编码的票据图片
return jsonify({"ticket": base64.b64encode(ticket).decode()}), 200
首先其需求一个json
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"])])
{
"number": 12345, // 整数 (10000-99999)
"section": "PLAYER", // 字符串 (必须为 PLAYER/VIP/BACKSTAGE)
"seat": ["A", 3], // 数组 [行, 号]
"type": "PAID" // 字符串 (必须为 FREE/PAID/ORGANIZER)
}
{"number":12345,"section":"PLAYER","seat":["A",3],"type":"PAID"}
我们没有key,所以唯一合法的票证是传递空票
接下来程序利用这些数据生成图形化票证
def add_text(img, text, pos):
# 检查文本是否只包含允许的字符
if not ALLOWED_PATTERN.match(text):
raise ValueError("Illegal characters used") # 非法字符
# 将OpenCV图像转换为PIL图像以便绘制文本
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))
# 将PIL图像转换回OpenCV格式
img[:] = cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
def generate_signature(data):
# 用HMAC-SHA256生成签名
return hmac.new(environ["SIGNATURE_SECRET_KEY"].encode(), data, hashlib.sha256).digest()
def generate_image_with_signature(img):
# 将OpenCV图像转换为PIL图像
img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
img_io = BytesIO()
img.save(img_io, format="JPEG") # 保存为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)
/enter
@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
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")
# 转为灰度图像以便OCR
gray_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY)
# 解析票据信息
return parse_ticket_info(gray_img)
def read_lines(img, n):
# 使用OCR读取指定区域的文本行
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)] # 返回前n行
def parse_ticket_info(img):
# 解析票据信息,返回三行内容
return read_lines(img, 3)
part 1
这些字符串都是合法的,可被json解析的合法字符串 from justctf Ticket Master
import json
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))
利用这个特性,我们可以提供一个意外的值绕过waf,此值可以被TicketData正常解析
[["seat", "A1iiiii"], ["section", "VIP"]]
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 422
part 2
seat = FieldList(
StringField(validators=[DataRequired(), Length(min=1, max=1)]),
IntegerField(validators=[DataRequired(), NumberRange(min=1, max=7)])
)
这里开发者显然想表示 seat = [行, 号]
(例如 ["A",3]
)。但实际写法让 FieldList
的子字段只有一个:单字符 StringField
,而不是“两个不同类型的元素”。结果:
-
当我们把
"seat": "A1iiiii"
作为一个字符串喂给FieldList
时,WTForms 会把它当成可迭代,据此创建若干个子条目:["A","1","i","i","i","i","i"]
。 -
每个子条目都是长度为 1 的字符串,完全满足
DataRequired + Length(min=1,max=1)
,因此验证通过。 -
业务侧的“合法性检查”只看前两个元素:
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 500
——也就是说它只验证
seat[0]
和seat[1]
,忽略其后的多余元素。 -
随后用于绘图的字符串是
''.join(data['seat'])
,因此多出来的"iiiii"
会被原样画到票上:seat = f"{data['section']}/" + ''.join(data['seat']) # 例如 "VIP/A1iiiii"
part 3
fuzz 一下尝试各种字符干扰图像识别即可