Two Ways To Mess Up Your JWT Safety Net In Your Own Lab.
Press enter or click to view image in full sizeIn the lab we’re going to build today, we’ll talk abo 2026-7-22 18:50:13 Author: infosecwriteups.com(查看原文) 阅读量:2 收藏

ShadowForge

Press enter or click to view image in full size

In the lab we’re going to build today, we’ll talk about JWTs and how they can affect the security of your website. This is a lab that’s been requested, and I could not be happier that the community is actually suggesting things for me to build. It’s a privilege to do this.

JWT As Security Measure

JSON Web Tokens are used a lot by developers. They provide a way to send ‘data’ with them (e.g., a role from a user), to check for authorization. A JWT consists of 3 parts, separated by a ..

Let’s take this JWT as an example.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

The first part eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 is called 'the header'. It holds information on the type of the JWT. The second part eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0 is the payload, which holds the claims. This is where that 'data' lives. If you decode this with Base64url you'll learn that this token belongs to John Doe and that he is an administrator. The last part is the important part. This is the signature. Each JWT should be signed with a strong password. Ideally that password is saved in a .env file on the server and not in the code.

So now you might wonder, well if it’s signed, nothing can go wrong right? Well, if that were true, I wouldn’t spend my evenings writing these kinds of blog posts now would I 😅.

Let’s Build The Lab Already

You’re right to think this by now. Enough theory, let’s do some hands on keyboard. As always in this series, I’ll provide you with the lab structure and some code to get us started.

Lab Tree

/mediumLabs
└── /JWT
├── server.js
├── login.html
├── index.html
├── admin.html
├── /node_modules
├── /package-lock.json
└── /package.json

Code

server.js

const express = require('express');
const jwt = require('jsonwebtoken');
const path = require('path');
const Database = require('better-sqlite3');
const app = express();
const JWT_SECRET = 'secret';
const db = new Database(':memory:');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT,
password TEXT,
role TEXT
)
`);
db.prepare("INSERT INTO users (username, password, role) VALUES ('user', 'password', 'user')").run();
app.use(express.urlencoded({ extended: false }));

app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'login.html')));
function getToken(req) {
const match = (req.headers.cookie || '').match(/token=([^;]+)/);
return match ? match[1] : null;
}
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) return res.redirect('/?error=Invalid+credentials');
const token = jwt.sign({ username: user.username, role: user.role }, JWT_SECRET);
res.setHeader('Set-Cookie', `token=${token}; Path=/`);
res.redirect('/index.html');
});
app.get('/index.html', (req, res) => {
try {
res.sendFile(path.join(__dirname, 'index.html'));
} catch {
res.redirect('/');
}
});
app.get('/admin', (req, res) => {
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

login.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="POST" action="/login">
<div>
<label>Username: <input type="text" name="username" required></label>
</div>
<div>
<label>Password: <input type="password" name="password" required></label>
</div>
<button type="submit">Login</button>
</form>
</body>
</html>

index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title>
</head>
<body>
<h1>Welcome!</h1>
<p>You are logged in as <strong id="username"></strong>.</p>
<p><a href="/admin">Go to Admin Panel</a></p>
<br>
<script>
function getCookie(name) {
return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1];
}
function decodeJWT(token) {
return JSON.parse(atob(token.split('.')[1]));
}
const token = getCookie('token');
const payload = decodeJWT(token);
document.getElementById('username').textContent = payload.username + ' (role: ' + payload.role + ')';
</script>
</body>
</html>

admin.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
</head>
<body id="page">
<h1>Admin Panel</h1>
<p>Welcome, admin. Here is the secret flag:</p>
<p><strong>FLAG{JWT}</strong></p>
<br>
<a href="/index.html">Back to Home</a>
</body>
<script>
function getCookie(name) {
return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1];
}
function decodeJWT(token) {
return JSON.parse(atob(token.split('.')[1]));
}
const token = getCookie('token');
const payload = decodeJWT(token);
if (payload.role !== 'admin') {
alert('Access denied. You need to be an admin to view this page.');
}
</script>
</html>

Let’s Add Some Vulnerabilities

Ok, so now we have our base code, and we need to implement the functionality that will make sure only people with the role admin can navigate to /admin. First off, run npm run dev to see your code actually start.

In a very, very wrong way we already tried implementing security in admin.html. When you look inside the <script> tags, you'll see that if the role is not admin, an alert will pop up. I added this because this was a real-life finding of mine. Some developer did not realise that even if your role isn't admin, you will be able to navigate to the page. The only 'annoying' thing is that you'll have to click OK on an alert box.

It’s important to realise that these mistakes happen more often than you think. Developers with a lot on their plate, little sleep and not enough coffee can truly believe this is ‘secure’.

So let’s now add some real security to the backend! Frontend ‘security’ is ridiculous to start with, so let’s do our very best.

Get ShadowForge’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

server.js

app.get('/admin', (req, res) => {
const token = getToken(req);
if (!token) return res.redirect('/');
const payload = jwt.decode(token);
if (!payload || payload.role !== 'admin') return res.status(403).send('Forbidden');
res.sendFile(path.join(__dirname, 'admin.html'));
});

Alright, let’s walk through this piece of code. First we look for the token — if there isn’t one, we redirect to /, which is a good strategy. Then we decode the JWT and check if the role is admin. If there's no token, or the role isn't admin, we get a 403.

Sounds solid, right? If everything checks out, the backend trusts you, because the token says you’re an admin.

So what’s the problem? The code does check if the role is admin, which is good. But it never checks whether that role actually belongs to you. That check should happen by verifying the JWT’s signature. This code doesn’t, because jwt.decode() only reads the payload, it doesn't verify anything. When the server originally signed this token, it signed it with role: user. If you change the payload to role: admin without re-signing it, the signature no longer matches what's in the token, but since nothing here checks the signature, that mismatch goes completely unnoticed.

That’s the bug: because that check never happens, we can just change the payload in Burp Suite and see the admin page.

Press enter or click to view image in full size

There is a very easy way to solve this though. Instead of using jwt.decode(token) a developer should always use jwt.verify(token, JWT_SECRET). This code makes sure that function will create a signature and check that signature with the signature that has been sent. If there was any tampering with the JWT, this signature won't check out and your access to the restricted endpoint is denied.

So let’s update our code.

server.js

app.get('/admin', (req, res) => {
try {
const payload = jwt.verify(getToken(req), JWT_SECRET);
if (payload.role !== 'admin') return res.status(403).send('Forbidden');
res.sendFile(path.join(__dirname, 'admin.html'));
} catch {
res.redirect('/');
}
});

If you now try the same thing, you’ll end up in the catch block, which will redirect you to / (our login page) because the signature will not check out.

Press enter or click to view image in full size

So all safe now, right? Well let’s try something else… If you ever want to crack a secret, I can recommend hashcat. It's available on all platforms and easily installable.

For this attack we’ll need mode 16500 (this is just a way to tell hashcat what we want to do). We'll also need the flag -a 0, which will tell hashcat to use a wordlist we're gonna provide. Lastly, you'll see -d 1 — that's because I'm on a Mac with an M-chip.

The complete command looks like this: hashcat -m 16500 -a 0 <JWT> rockyou.txt -d1.

Don’t forget to use the untampered token for this!

➜  wordlists hashcat -m 16500 -a 0 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJyb2xlIjoidXNlciIsImlhdCI6MTc4MzEwNDc0OX0.myFrub4u8yG3IeItDYKO-2Vci6sMfLaJ1OrlQDeuQfc rockyou.txt -d1
hashcat (v7.1.2) starting
<skip>
Dictionary cache hit:
* Filename..: rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIiLCJyb2xlIjoidXNlciIsImlhdCI6MTc4MzEwNDc0OX0.myFrub4u8yG3IeItDYKO-2Vci6sMfLaJ1OrlQDeuQfc:secret

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 16500 (JWT (JSON Web Token))
Hash.Target......: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZS...DeuQfc

As you can see, at the end of our JWT there is a colon with our JWT_SECRET written behind it.

Now let’s abuse this knowledge we have gained. In Burp Suite we’ll use functionality that will sign our tampered JWT so that the code will verify it, and the signature will check out.

In Burp Suite, click on the JWT editor on the top right corner, and choose the option ‘New Symmetric Key’. Once you have that, click specify secret and fill in the cracked JWT_SECRET in there. Give it an ID and click OK.

Press enter or click to view image in full size

Once you have that, go back to Repeater, change the role back to ‘admin’, click sign and select the ID you just made.

Press enter or click to view image in full size

And now, we have successfully evaded the security that was in place.

Press enter or click to view image in full size

Lessons We Should Learn About This

As you saw, correctly implementing a JWT is only the first step of coding securely. Having a strong password policy, even when it’s not really enforceable, is very important. Don’t use ‘secret’ as your JWT_SECRET

One thing I already mentioned, but what I want to stress again is NEVER EVER store your JWT secret as a hardcoded variable, like we did with JWT_SECRET = 'secret'. Your code will probably live on GitHub where all kinds of stupid things can happen. A secret belongs in a .env file that is in your .gitignore and lives only on your dev machine and on the server, NOT in your code.

I break web apps for fun, make vulnerable labs to learn, and write about it so you can too.


文章来源: https://infosecwriteups.com/two-ways-to-mess-up-your-jwt-safety-net-in-your-own-lab-6d94a963b07d?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh