Over the weekend I participated in LineCTF 2023. This was a really fun CTF with lots of great web challenges. Often web challenges in CTFs are either really contrived, really guessy or really easy. It was nice to see a CTF with a large number of high quality web challenges that were challenging while still feeling realistic and not guess based.
Overall I didn't get too many challenges during the competition. However I did solve one challenge that nobody else did: Memento. It was the only web challenge to have only one solve, and I honestly feel pretty proud of myself for getting it. Not to mention that it makes me feel a lot better about having no idea how to solve most of the other problems :). In the end I came 28th with 601 points.
The challenge
We are given a Java Spring application that allows you to store notes and view the list of notes you have previously stored. The notes themselves are not access controlled, but are stored under an unguessable UUID. There is an admin bot which you can ask to look at a url. If you do, it will store a note containing the flag and then look at the url of your choosing.
To trigger this bot action there is a a /bin/report endpoint:
@RequestMapping("/report")
public String report(@RequestParam String urlString) throws Exception {
URL url = new URL(urlString);
HttpClient.newHttpClient().send(HttpRequest.newBuilder(new URI("http://memento-admin:3000/?url=" + url.getPath())).build(), HttpResponse.BodyHandlers.ofString()).body();
return "redirect:/" + url.getPath() + "#reported";
}
Which then triggers a node.js app that runs headless chromium:
// post flag as anonymous user
console.log(origin + "/bin/create");
await page.goto(origin + "/bin/create");
await page.type("textarea", FLAG);
await page.click("button");
// visit to reported url
await page.goto(origin + url);
The other important endpoints is the list and create endpoints:
@GetMapping("/list")
public String binList(Model model) {
if (authContext.userid.get() == null) return "redirect:/";
model.addAttribute("bins", userToBins.get(authContext.userid.get()));
return "list";
}
@PostMapping("/create")
public String create(@RequestParam String bin) {
String id = UUID.randomUUID().toString();
if (userToBins.get(authContext.userid.get()) == null) {
userToBins.put(authContext.userid.get(), new ArrayList<String>());
}
userToBins.get(authContext.userid.get()).add(id);
idToBin.put(id, bin);
return "redirect:/bin/" + id;
}
Not an XSS
At first glance, i assumed this was going to be some sort of XSS. Typically when you see a bot process that does something with confidential data then goes to a url of your choosing it is some sort of client side vulnerability. However, i looked, and there was clearly no opportunity for XSS or more obscure client-side data leaks.
If not XSS, then where to next? If its not client side, we must need to get the secret note directly somehow. Guessing the UUID seemed impossible, so that left the /list endpoint. Clearly we needed some way to see the list of the admin bot's notes. With that in mind, maybe there is something about session generation that would allow us to steal their session. Here is the session auth code:
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private AuthContext authContext;
private static String COOKIE_NAME = "MEMENTO_TOKEN";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Cookie cookie = WebUtils.getCookie(request, COOKIE_NAME);
if (cookie != null && !cookie.getValue().isEmpty()) {
try {
String token = cookie.getValue();
String userid = JwtUtil.verify(token);
authContext.userid.set(userid);
return true;
} catch (Exception e) {
// Failed to verify jwt
}
}
String userId = UUID.randomUUID().toString();
cookie = new Cookie(COOKIE_NAME, JwtUtil.sign(userId));
cookie.setPath("/");
response.addCookie(cookie);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
authContext.userid.remove();
}
}
public class AuthContext {
public ThreadLocal<String> userid = new ThreadLocal<String>();
}
Alright, so the app checks if the user has a cookie. If not, gives them a new JWT cookie with a session id. The current user's id is stored in thread local storage at the beginning of the request, and cleared at the end of the request.
First thing I tried was the usual JWT vulns, setting alg = NonE, etc but to no avail.
However, one thing did stand out in this code - the postHandle. The current user id is essentially being stored in a (thread specific) global variable. I'm not that familiar with Java, but given that it is explicitly being cleared towards the end of the request, one assumes that that is neccessary and otherwise the thread local storage would persist across HTTP requests.
Attacking session lifetime
Thus an (incorrect) plan started to form based on a session fixation attack:
- Somehow cause postHandle() not to be run at the end of the request
- Send a request to fix the session to one of my choosing
- Have the admin bot go post something under my chosen session
- View the /bin/list endpoint with my chosen session cookie, thus getting the id of the flag note
- Fetch the flag
I'll get to the incorrect assumption I made here in a little bit. First things first, how do we make postHandle() not run?
We need some way to change the control flow of the process to bypass the postHandle. A good way to alter the control flow of a program is to throw an exception. Luckily for us, java requires methods to annotate if they throw exceptions so its really easy to see possible triggers. As we can see from the code, the report endpoint can throw an exception. A quick look at the Java docs shows that the URI constructor can throw a "URISyntaxException
- If the given string violates RFC 2396, as augmented
by the above deviations".
This all sounds very promising. After all, we control the URL that we are reporting. Some quick experiments later, and it seems like having a url with %7F in it triggers a 500 error. This looks really promising.
So lets test this theory. Can we retrieve the bin list without specifying the cookie?
curl 'http://172.17.0.1:10000/bin/create' --data 'bin=MyTest' -i -H 'Cookie: MEMENTO_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ZDA3NDY5ZC1jMmM5LTRkNzItYWMyYS0xZjRkMTA4YmFjMDAifQ.MtOeLRzaBI_y97M_Pr0eQ56bZwVia2tMGpUspj_NEGg' | grep Location
Location: http://172.17.0.1:10000/bin/bcab05e5-2fb6-4b8c-aed5-1a18c5fde4be
curl 'http://172.17.0.1:10000/bin/report?urlString=http://172.17.0.1:10000/bin/%7f' -i -H 'Cookie: MEMENTO_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ZDA3NDY5ZC1jMmM5LTRkNzItYWMyYS0xZjRkMTA4YmFjMDAifQ.MtOeLRzaBI_y97M_Pr0eQ56bZwVia2tMGpUspj_NEGg'
{"timestamp":"2023-03-29T14:35:17.546+00:00","status":500,"error":"Internal Server Error","path":"/bin/report"}
curl 'http://172.17.0.1:10000/bin/list' -i
[Repeat a few times to account for multiple threads]
[..]
<tr class="row">
<td>
<a href="/bin/bcab05e5-2fb6-4b8c-aed5-1a18c5fde4be">bcab05e5-2fb6-4b8c-aed5-1a18c5fde4be</a>
</td>
</tr>
[..]
Success! We were able to run the list command getting the results for the previous user without including their cookie.
Translating to a real attack
Initially my plan of attack was:
- Make the exception be thrown to fix the session. Repeat several times to hit all the threads
- Report a url
- Assume the admin bot will make a post to a thread that has the fixed session
- View the list endpoint using my cookie
This did not work. So I took another look at what the admin bot actually does:
// post flag as anonymous user
console.log(origin + "/bin/create");
await page.goto(origin + "/bin/create");
await page.type("textarea", FLAG);
await page.click("button");
// visit to reported url
await page.goto(origin + url);
I had originally saw the "// post flag as anonymous user" comment, and assumed that meant that normally it is posted without any cookies. However that is wrong. First the bot makes a GET request to load the form, which sets up the cookies. Thus the flag POST is actually authenticated and not anonymous.
This lead to a new plan of attack:
- Have the reported url, which the bot goes to after posting the flag trigger the exception
- The session will now be fixed to whatever was used to POST the flag
- View the list of notes with no cookie, repeating multiple times until we get the thread with the fixed session that the admin bot used.
Specificly, we'll do:
curl 'http://176.17.0.1:10000/bin/report?urlString=http://176.17.0.1:10000/bin/report%253furlString=http://176.17.0.1:10000/bin/%257f'
followed by a bunch of
curl 'http://34.84.65.148:31337/bin/list'
Eventually I got the list including the name of the note with the flag, and curled that note.
Success!
Then i tried it on the real server, but kept getting 404 not found from openresty. Eventually I realized there was an additional cookie i needed for the real server. Guess I was pretty tired at that point. Once I fixed that, we succeeded:
LINECTF{d43f859493cc297c18c68ad241ba04de}
Conclusion
This was a fun challenge. One that was very fresh, but also seemed realistic.
I will say to all the PHP haters out there, that this sort of thing could never happen in php ;)