DEV Community

RivenX173
RivenX173

Posted on Originally published at rivenx173.Medium

Silent HMAC Key Contamination: Uncovering a Logic Flaw in Burp's JWT Editor Extension

How a failing Web Security Academy lab led to a root-cause analysis of a hidden bug

AI-generated image via ChatGPT

JWT Editor was shortlisted for "Best Auth & Access Control" in PortSwigger's 2026 Burp Suite Extension Awards. This is the story of finding a silent bug inside it.


Usually, when something goes wrong, your first instinct is to look at yourself. What did I do wrong? Which step did I miss? It takes a lot to get to the point where you seriously consider that the mistake isn't yours at all: it's the tool's.

It's a bit like a developer insisting their code is broken because of VS Code itself. Especially when everyone around you is saying the opposite, and your own eyes keep telling you the same thing they're saying. But sometimes you have to hold onto an old piece of advice:

"Once you eliminate the impossible, whatever remains, no matter how improbable, must be the truth."
— Arthur Conan Doyle (Sherlock Holmes)

This is the story of how a training lab that "shouldn't have been failing" turned into a fifteen-hour investigation, a silent bug, and a GitHub issue against the #1 most popular extension in the Burp Suite BApp Store as of today.

JWT Editor ranked #1 in popularity in the BApp Store

Some Background: What Is JWT Algorithm Confusion?

Before the story makes sense, you need the theory behind it. JWT algorithm confusion is a class of vulnerability that stems from how some backend libraries implement token verification. Some implementations write code like this:

publicKey = <public-key-of-server>;
token = request.getCookie("session");
verify(token, publicKey);
Enter fullscreen mode Exit fullscreen mode

The problem is that if the server receives a token signed with a symmetric algorithm like HS256 instead of the expected asymmetric RS256, some libraries' generic verify() method will happily treat the public key (which is, by definition, public and known to anyone) as if it were an HMAC secret. If an attacker can get their hands on that public key, they can sign their own token with it using HS256, and the server will trust it.

If you want the full technical breakdown, PortSwigger's own write-up on algorithm confusion covers it properly. I'm only summarizing it here because it's the whole reason I ended up down this rabbit hole in the first place.

The Lab, and the First 50 Minutes of Denial

Once I'd finished the theory, I moved to the practical lab: JWT authentication bypass via algorithm confusion.

The intended solution is straightforward: grab the exposed public key from the lab's /jwks.json endpoint, convert it to PEM, Base64-encode it, use that as the HMAC secret, switch the header's alg from RS256 to HS256, change the payload's sub from wiener to administrator, sign the forged token, log in, and delete carlos's account. Lab solved.

Except it wasn't. I followed every step exactly, right up to sending the forged token, and it was never accepted. No administrator access, no matter how many times I repeated it. After about 50 minutes on my own, I assumed I was missing something before that final signing step, so I opened the official walkthrough. It was exactly what I'd already done, word for word. So I told myself: maybe I'm misreading the intent of a step, even if the words are right.

So I went looking at community solution videos. Here's where it got strange: three or four different people, same steps, same order, no hidden tricks. Comments underneath were full of people thanking them, confirming it worked. So why wasn't it working for me?

At that point I could've closed the lab, marked it "watched the solution," and moved on. But that never sits right with me. "It happens because it happens" has never been a satisfying explanation for me. If something isn't working, there's a reason, you just haven't found it yet.

Ruling Out the Obvious: Isolating Variables

So I decided to take a more disciplined approach: isolate every variable, one at a time, instead of assuming.

First variable: is this lab instance broken?

I closed the lab and waited about 15 minutes for it to disconnect. If you've used PortSwigger's Academy labs before, you know they're not static; the internal state (including the public key at /jwks.json) regenerates on a fresh instance, and even credentials you've already used will stop working after a while, requiring you to redo the earlier steps to get back to where you were.

After a short break and a cup of black coffee, I opened a brand-new instance and repeated everything carefully. Still failed. Okay, that rules out "this specific lab instance is glitched." The problem wasn't the lab.

Second variable: is the exposed public key itself wrong?

Maybe, I thought, PortSwigger had recently changed something about how the lab exposes its key at /jwks.json, and the "official" approach was simply outdated. So I decided to solve the lab a completely different way, using an independent tool: rsa_sign2n (a simplified fork of jwt_forgery.py), run via Docker:

docker run --rm -it portswigger/sig2n <token1> <token2>
Enter fullscreen mode Exit fullscreen mode

This tool takes two JWTs signed with the same RSA key and derives the key material from them, outputting:

  • A Base64-encoded PEM key, in both X.509 and PKCS1 format.
  • A forged JWT signed with each of these derived keys.

Since I knew this lab used an X.509-format key, I copied the public key with that format, and went into JWT Editor. I created a New Symmetric Key, pasted the value into the k parameter, saved it, changed wiener to administrator, switched the header's alg to HS256, signed the token, and sent the request.

It worked. No unauthorized response. The forged token was accepted. carlos deleted. Lab solved.

The Comparison That Changed Everything

Now I had something concrete: a public key that worked (from rsa_sign2n) and a public key that didn't (from following the official steps). I re-ran the original method again just to be sure, and, as expected, it failed again.

Looking at both Base64-encoded strings side by side, there was a visible difference between them. My first assumption was that the lab's /jwks.json endpoint was serving a genuinely broken key. But when I Base64-decoded both of them back into PEM format:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..
Enter fullscreen mode Exit fullscreen mode

They were identical. Same characters, same order, no difference at all. My first theory was wrong. But there was still clearly something different between the two of them: something invisible in Burp's UI, something the human eye wasn't catching.

With a slight smile forming, I opened Burp Comparer, pasted both PEM versions in (before any Base64 encoding), and switched to Hex view. About four hours in, the difference finally showed itself, plain and unmistakable.

Video Source

One of the two was full of extra bytes 0D. Going back to the Decoder's Hex view, I could see that 0D appeared exactly once, at the end of every single line.

Note: 0D is the hex value for a Carriage Return (\r), while 0A is the hex value for a Line Feed (\n).

A Quick Detour: Why Do Line Endings Even Differ Between Operating Systems?

This goes back to mechanical teletypewriters, decades before any of the operating systems we use today existed. A teletypewriter needed two separate physical actions to start a new line: a Carriage Return (CR) to send the print head back to the start of the line, and a Line Feed (LF) to advance the paper by one line. Early computer systems inherited this literally, sending both characters, \r\n, to represent a new line.

When Unix was designed, Ken Thompson made a deliberate simplification: use \n alone as the line terminator, since a single character was enough on a screen (no physical print head to return). Classic Mac OS, on the other hand, went the opposite way and used \r alone. Microsoft's MS-DOS, and later Windows, kept the original \r\n pair for compatibility with older systems and printers, and that convention has stuck around ever since, all the way to the Windows 11 I was using in 2026.

Modern macOS, being Unix-based since OS X, switched to \n like Linux. So today, in practice, it comes down to: Windows uses \r\n; Linux and macOS use \n.

It's a little funny, honestly: a bug appearing in 2026 because of a decision made decades ago, for a piece of hardware that stopped existing before most of us were born.

Reverse-Engineering Backwards

After the discovery, I felt considerably calmer. I finished my coffee, stepped outside for some air for about half an hour (four hours of near-continuous sitting had left my body stiff) and came back with a clear head.

I went back into the lab, but this time as a different person: I knew what the problem was; now I just needed to reverse-engineer where exactly it was being introduced, all the way back to the point where I pasted the public key into Burp Decoder, before any Base64 encoding.

Step one: copy the key directly from /jwks.json and paste it into Decoder's Hex view. Result: no extra bytes.

Step two: convert that same key to PEM using JWT Editor, then paste that into Decoder's Hex view. Result: the bytes appear.

The culprit was JWT Editor. But did the corruption happen during copying, or during pasting? The surprising answer was: neither. It happens before any copy-paste at all. Going back into JWT Editor and generating a brand-new, random RSA key, then converting it to PEM, the bytes were already there, automatically. I confirmed this in the simplest way possible: placing the cursor at the end of any line and pressing Backspace. The visible character wasn't deleted on the first press; it took a second press. That first, invisible deletion was the hidden \r.

To confirm that none of the available copy methods were stripping the \r, I tested every one of them: Ctrl+C, right-click copy, and the extension's own "Copy Public Key as PEM" button. Every single one produced the exact same result once pasted into Decoder: the extra 0D bytes were always there.

Video Source

Into the Source Code

I went to the extension's page on the BApp Store, found its link to PortSwigger's Github repository, and traced it to the maintainer's repository.

Eventually, I landed on the exact file responsible: PEMUtils.java.

The relevant method:

public static String pemObjectToString(PemObject pemObject) throws IOException {
    StringWriter stringWriter = new StringWriter();
    PemWriter pemWriter = new PemWriter(stringWriter);
    pemWriter.writeObject(pemObject);
    pemWriter.close();
    stringWriter.close();
    return stringWriter.toString();
}
Enter fullscreen mode Exit fullscreen mode

PemWriter comes from BouncyCastle (org.bouncycastle.util.io.pem.PemWriter), and it directly extends java.io.BufferedWriter. It doesn't define its own line-break logic: when writeObject() inserts line breaks (every 64 Base64 characters, per RFC 1421, plus around the header/footer), it relies on the inherited BufferedWriter.newLine() method. Per the JDK documentation, that method "writes a line separator... defined by the system property line.separator," which defaults to \r\n on Windows. Since pemObjectToString() returns the raw string with no normalization step, whatever the host OS's line separator is gets baked directly into every PEM string the tool produces.

  • Setup note: the exact environment I was running when I found this was: Windows 11 Pro, Version 25H2 (OS Build 26200.8875), Burp Suite Community Edition v2026.7.2, JWT Editor version 2.6.1 (updated 23 Apr 2026). I'm noting this specifically because both Burp and its extensions update regularly; this reflects the state of things at the time the bug was found, not necessarily whatever version you're reading this on.

The fix itself is small and low-risk:

public static String pemObjectToString(PemObject pemObject) throws IOException {
    StringWriter stringWriter = new StringWriter();
    PemWriter pemWriter = new PemWriter(stringWriter);
    pemWriter.writeObject(pemObject);
    pemWriter.close();
    stringWriter.close();

    // Normalize line endings for consistent cross-platform output
    return stringWriter.toString().replace("\r\n", "\n");
}
Enter fullscreen mode Exit fullscreen mode

Worth noting: this line is completely safe on every platform. .replace() only acts when it finds a match. On Linux and macOS, where the output is already \n-only, there's nothing to replace; the string comes back untouched.

Why This Went Unnoticed

Most people working in web security and bug bounty hunting use Linux or macOS, not native Windows, whether that’s a pentesting distro, WSL, or a Mac. On both, this bug simply doesn’t exist, since their line separator is already \n. The solution videos I watched, where the authors were using JWT Editor extension, and the comments thanking them for a walkthrough that "just worked," almost certainly all originated from Linux or macOS. Even if someone failed to solve this on Windows, they would probably assume it was just a lab error or a mistake on their end. If they did realize it was a bug, they wouldn't spend hours trying to figure it out in a simple lab, especially with such a silent issue, so they would probably just move on.

A Manual Workaround Exists, But...

There is a trivial workaround: pasting the exported PEM into an external text editor and copying it back out strips the \r, since most standard text applications don't preserve it on that round trip. But there's no logical reason to bounce your key through an external editor unless you already suspect the bug exists.

Reporting It

Once I had a confirmed root cause, a working fix, and a reproducible demonstration, I put together a full report and opened issue #248 directly on the maintainer's repository.

From the very first attempt at the lab to submitting that issue, the whole thing took roughly ten hours of actual focused work, not counting the short breaks in between.

Testing the Fix Myself

While waiting for a response from the maintainer, I decided not to just leave the fix as theory. On August 07, 2026, I loaded the original extension’s JAR file into Recaf, a tool for editing compiled Java bytecode directly, and applied the exact one-line fix from the report directly to the compiled class.

With the patch in place, I exported the JAR as a separate test build (renamed to "JWT Editor (Test Build)" to avoid any confusion with the original) and loaded it into Burp as a new extension. It worked: generating a fresh RSA key and converting it to PEM no longer produced the extra 0D bytes, and the rest of the extension's functionality behaved exactly as expected.

Video Source

And once I confirmed the fix worked, I opened pull request #249 against the maintainer's repository with the same one-line change, so it could be reviewed and merged into the official codebase.

Furthermore, I tested this patched build on Kali Linux, confirming that the LF normalization works flawlessly across environments and will absolutely not break the extension for macOS or Linux users.

Between bytecode editing in Recaf, testing the build, and putting together the pull request, it took me an additional five hours.

First Response and Discussion With the Maintainer

On August 21, 2026, I got the first response from the maintainer, and after a long and detailed discussion, here's where things stand:

  • A) The official lab solution is still broken for Windows users if followed exactly as written. The maintainer declined to merge my pull request #249, and I now agree with his reasoning (details below).
  • B) The HMAC Key Confusion attack feature in the tool has since been improved and now handles this scenario correctly, including for Windows users. This gives a more reliable path through the lab (also below).

The maintainer and I ultimately agreed this isn't really a bug in the extension itself: "Copy as PEM" is meant as a general export feature, so platform-specific line endings make sense there. It wasn't designed for a workflow that treats the exported text as raw secret bytes, which is what this attack does. That's why the PR was declined.

There's also a more practical reason my fix wasn't the ideal solution, even setting the "design intent" argument aside: it normalized every PEM output to Linux-style (\n) line endings. That works for this specific lab, since Linux-hosted backends are the common case, and it does solve it. But it's not the most robust fix, because if the backend server happened to be Windows-based instead, the exact same key-confusion mismatch could resurface in the opposite direction: the PEM export would be \n-only while the server's stored key material used \r\n. Hardcoding one platform's convention solves the common case, but doesn't fully generalize to every backend.

The Better, Updated Way to Solve the Lab and Perform the Attack

The maintainer has since built proper support for this scenario directly into the HMAC Key Confusion attack feature, released in version 2.6.2 on September 04, 2026.

JWT Editor 2.6.2 release

The updated, more reliable workflow is:

  1. 1. Visit the /jwks.json endpoint and copy the JSON
  2. In JWT Editor, click "Import JWK Set" (bottom right).
  3. Paste the JSON and click Import.
  4. Get a valid JWT in Repeater, then edit the sub field.
  5. Click Attack, then select "HMAC Key Confusion."
  6. Pick the key you just imported.
  7. Choose one of the four line-ending options depending on the backend server's platform and how it stores its PEM key:
    • Linux/macOS (0x0A)
    • Linux/macOS (0x0A) – no trailing newline
    • Windows (0x0D0A)
    • Windows (0x0D0A) – no trailing newline

(Most real-world backends are Linux-hosted, as in the PortSwigger lab, so the first option is usually the right starting point. That said, even on a Linux backend it's worth trying the other Linux/macOS option too if the first one doesn't work, since not every server handles the PEM key identically.)

  1. Click OK. The forged token now has a valid signature.

HMAC Key Confusion attack dialog in the JWT Editor 2.6.2 release

Important note: if you can't find the "Import JWK Set" button in the bottom right, make sure the Scaling option is enabled in Burp Suite, and keep Burp's display font size under 18.

Burp Suite's scaling option

This matters because using too large a font size, or a Windows display scale setting above 100% (under Windows display settings), causes the "Import JWK Set" button to get cut off in Burp Suite's UI, as shown below.

Import JWK Set

One last thing worth mentioning: when I first started chasing this, I was only 83 days into cybersecurity. And it only happened because "it happens because it happens" never satisfies me.

Reaching Out to PortSwigger

I didn't mention this earlier in the writeup, but as soon as I ran into the bug, I sent an email to PortSwigger at [email protected]. They responded and pointed me to the maintainer's repository, where I had already opened Issue #248 the day before. But even after everything was resolved, the bug still exists in the lab's official solution and in the theoretical explanation on PortSwigger's site for Windows users, so I followed up with a second email. Here's their reply:

PortSwigger's reply to the follow-up email

If they make any changes on their end, whether in the theoretical write-up, the lab's official solution, or in a follow-up email to me, I'll update this write-up accordingly.

Timeline

  • Aug 03, 2026: Sent an initial email to PortSwigger about the bug.
  • Aug 03, 2026: Root cause identified after isolating variables in the lab.
  • Aug 03, 2026: Submitted GitHub Issue #248 to the maintainer's repository with a detailed PoC.
  • Aug 04, 2026: PortSwigger replied, pointing me toward the maintainer's repository, where I had already opened Issue #248 the day before.
  • Aug 07, 2026: Patched the bytecode locally via Recaf to verify the fix on a custom test build.
  • Aug 07, 2026: Submitted pull request #249 containing the cross-platform line-ending normalization fix.
  • Aug 21, 2026: Received the first response from the maintainer.
  • Aug 21 – Sep 01, 2026: Discussion with the maintainer.
  • Sep 04, 2026: JWT Editor 2.6.2 released.
  • Sep 04, 2026: Sent a follow-up email to PortSwigger after the extension update, noting the lab solution and docs still weren't updated.
  • Sep 07, 2026: Received PortSwigger's reply to the follow-up email.

"Silent bugs don't raise flags; you have to look under the hood."

Top comments (0)