zip -j trust_me_bro.jar - How One Command Cracks Elasticsearch Enterprise

zip -j trust_me_bro.jar - How One Command Cracks Elasticsearch Enterprise

in

What’s this about?

I occasionally build stuff, and one of the most used Ansible playbooks in my projects is the ELK container. I definitely recommend this project for anyone who dabbles in the warez and red teaming due to the 30-day platinum license it comes with on container provisioning. ELK is a great SIEM unlike QRadar and shit cough cough and it’s also open sourced, so we get to see what it does even though what we ended up doing is just diabolically unethical.

Due to my laziness and also forgetting to re-arm the Elastic container every 30 days, my sandboxed labs lose their features especially telemetry and sorts from its agents. So I asked myself, surely there’s a better way to do this than just keep re-arming the container every 30 days which seems to be a chore. That’s when I decided to take a peep into the internals of ELK and how the licensing model works.

What came out of it was that Elasticsearch uses a cryptographic licensing system to verify and differentiate their tiers of licensing, so premium features like machine learning, LLM agents, more advanced security features are all modelled on an enterprise license.

Turns out you can replace just a single file inside one of Elasticsearch’s JAR files, restart Elasticsearch, upload your own self-signed license and have a fully functional enterprise SIEM to muck about.

Unethically resourcing open-source projects

Tested on: Elasticsearch 9.3.0 (Docker) and Elasticsearch 8.13.1 (bare metal DEB on Ubuntu 22.04)

Disclosure: Oh yeah, this was coordinated with the Elastic security team prior to publication.

The Tinkering Process

“What does a license even look like?”

I initially started by pulling the current license from a running ES instance in my lab to see the format of the file and what it firstly contains:

curl -sk -u elastic:password 'https://localhost:9200/_license'

This returned a JSON object with fields like uid, type, issued_to, expiry_date_in_millis, and a big Base64 blob called signature. I must admit, seeing the expiry date in milliseconds kinda stumped me as it wasn’t the thing I expected lol, however, the signature immediately stood out, it was clearly doing something cryptographic, but what exactly? Since it’s a Base64 blob, mandatory to inspect its decoded output cause hackerman

“What’s inside that signature blob?”

Decoding the Base64 signature ends up finding that it isn’t just a raw RSA signature, but it also had a structure format (as seen below):

[4 bytes] Version number (integer, big-endian)
[4 bytes] Length prefix → random bytes (nonce)
[N bytes] Magic bytes (random nonce)
[4 bytes] Length prefix → encrypted content
[N bytes] AES-encrypted copy of license JSON (Base64-encoded)
[4 bytes] Length prefix → RSA signature
[256 bytes] RSA signature (SHA-512 with RSA-2048)

The 256-byte signature meant RSA-2048. The encrypted content was a mystery at this point since I was curious whether it was part of the verification, or just metadata?

“How does ES actually verify this?”

To answer the problems I’ve created upon myself and since Elasticsearch’s x-pack code is open source, we went straight to LicenseVerifier.java. The verification flow was surprisingly straightforward:

  1. Load a public key from public.key (a file inside the x-pack-core JAR)
  2. Rebuild the license JSON from its fields (not from the signature blob)
  3. Check the RSA signature against the rebuilt JSON

The encrypted content inside the signature blob? Never verified. Only the RSA signature over the rebuilt JSON matters. This was the first key insight. Naturally, I went on to look where the public key resides.

“Where does ES keep the public key?”

Inspecting further within my SIEM server, I noticed the public.key resides in x-pack-core-*.jar. A JAR file is just a ZIP archive. To look further into what is inside the JAR file, we extracted it:

jar xf x-pack-core-9.3.0.jar public.key

X.509 DER-encoded RSA-2048 public key. Standard format, nothing special.

“Is the JAR protected?”

bonk-inspection

This was the critical question. We checked for many things, but what eventually laid the foundation to what I did later on was:

  • JAR signing? No. No META-INF/*.SF or META-INF/*.RSA signature files.
  • Hash verification? No. ES doesn’t checksum its own modules on startup.
  • Runtime integrity checks? No. Nothing in the code compares the JAR against a known-good hash.
  • Tamper detection? None whatsoever.

The entire licensing system’s trust anchor is a plain file inside an unsigned ZIP archive.

JAR contents - no signing artifacts

“What if we just… replace it?”

The theory was simple and for some reason, path of least resistance always leads to something fruitful: if we replace public.key with our own public key, ES will trust signatures made with our private key. So we tested it:

# Generate our own key pair
openssl genrsa -out private.pem 2048

# Extract public key in the format ES expects (DER)
python3 elastic_forge.py extract-pubkey --key private.pem

# Swap it into the JAR (one command)
zip -j x-pack-core-9.3.0.jar public.key

# Restart ES
docker restart elasticsearch

ES started normally. No errors, no warnings, no “tampered JAR detected” messages. It just loaded our key instead of Elastic’s.

Public key replacement - one command

The Crypto Internals

Now that we know the attack surface, let’s dig into how the licensing crypto actually works.

What Gets Signed

Elasticsearch serializes the license fields in a specific order (via toInnerXContent with license_spec_view=true) and signs the result. The exact field order matters since from experience, getting it wrong the signature won’t match at all.

Version 5 (ES 8.x/9.x - current):

{"uid":"...","type":"enterprise","issue_date_in_millis":1234567890000,"expiry_date_in_millis":9999999999999,"max_nodes":null,"max_resource_units":1000,"issued_to":"...","issuer":"...","start_date_in_millis":1234567890000}

Key details:

  • Version 5 adds max_resource_units (required for enterprise licenses, null for others)
  • max_nodes is serialized as a nullable Integer (null when -1)
  • The signature field itself is excluded from the signed content
  • Compact JSON - no spaces

The Hardcoded Crypto Constants

The AES encryption layer uses values that are hardcoded right there in the open-source code (CryptUtils.java):

Parameter Value
Salt thisisthesaltweu
Passphrase elasticsearch-license
KDF PBKDF2-HMAC-SHA512, 10,000 iterations
Cipher AES-128-ECB

Anyone can decrypt the encrypted content inside any Elasticsearch license signature. This layer provides zero confidentiality. The AES key derivation looks like this:

AES_SALT = bytes([0x74, 0x68, 0x69, 0x73, 0x69, 0x73, 0x74, 0x68,
                  0x65, 0x73, 0x61, 0x6C, 0x74, 0x77, 0x65, 0x75])  # "thisisthesaltweu"
AES_PASSPHRASE = b"elasticsearch-license"

kdf = PBKDF2HMAC(
    algorithm=hashes.SHA512(),
    length=16,  # 128-bit AES key
    salt=AES_SALT,
    iterations=10000,
)
aes_key = kdf.derive(AES_PASSPHRASE)

Verification Flow (LicenseVerifier.java)

To summarize the full verification chain:

  1. ES starts up
  2. Loads public.key from the x-pack-core JAR (X.509 DER format)
  3. Reads the license from cluster state
  4. Rebuilds the license JSON from its fields (spec view mode)
  5. Extracts the RSA signature from the signature blob
  6. Runs SHA512withRSA verify(rebuilt_json, rsa_signature, public_key)
  7. If valid → license accepted. If not → rejected.

The cryptography itself is sound as SHA512 with RSA is not breakable. But crypto is only as strong as its trust anchor. And that trust anchor is an unprotected file inside an unsigned archive.

Forging a License

Building the Signature Blob

unethical-bonk

The signature blob needs to match the exact binary format ES expects. Here’s the key part, assembling the version header, nonce, encrypted content, and RSA signature:

def build_signature_blob(spec_json_bytes, private_key, aes_key):
    # Random nonce (ES calls these "magic bytes")
    magic = os.urandom(13)

    # Encrypt the spec JSON with AES (not verified, but ES expects it)
    encrypted = encrypt_aes_ecb(spec_json_bytes, aes_key)
    encrypted_b64 = base64.b64encode(encrypted)

    # Sign the spec JSON with RSA - this is what actually matters
    rsa_sig = private_key.sign(spec_json_bytes, padding.PKCS1v15(), hashes.SHA512())

    # Assemble: version(4) + magic_len(4) + magic + hash_len(4) + encrypted + sig_len(4) + sig
    blob = b""
    blob += struct.pack(">I", LICENSE_VERSION)
    blob += struct.pack(">I", len(magic))
    blob += magic
    blob += struct.pack(">I", len(encrypted_b64))
    blob += encrypted_b64
    blob += struct.pack(">I", len(rsa_sig))
    blob += rsa_sig

    return base64.b64encode(blob).decode("ascii")

The Full Exploit Chain

Step 1: Generate an RSA-2048 key pair

openssl genrsa -out private.pem 2048

Step 2: Extract the public key in DER format (what ES expects)

python3 elastic_forge.py extract-pubkey --key private.pem

Step 3: Replace the public key inside the x-pack JAR

# For Docker deployments:
docker cp public.key ecp-elasticsearch:/tmp/
docker exec -u root ecp-elasticsearch bash -c \
  "cd /tmp && zip -j x-pack-core-*.jar public.key && \
   cp /tmp/x-pack-core-*.jar /usr/share/elasticsearch/modules/x-pack-core/"

Step 4: Restart Elasticsearch

docker restart ecp-elasticsearch

Step 5: Generate a forged Enterprise license

python3 elastic_forge.py generate \
  --key private.pem \
  --type enterprise \
  --issued-to "My Org" \
  --days 26800

Step 6: Upload the forged license

curl -sk -XPUT 'https://localhost:9200/_license?acknowledge=true' \
  -u elastic:password \
  -H 'Content-Type: application/json' \
  -d @forged_license.json

Results

Bonk-achieved

{"acknowledged": true, "license_status": "valid"}

All 26 X-Pack features unlocked. ES reported it as a legitimate Enterprise license. Kibana displayed it normally. No indication whatsoever that it was forged.

Kibana License Management - forged Enterprise license active

GET /_license response - note issuer is "elastic-forge"

All premium features unlocked in Kibana

We tested on two completely different deployments:

  Deployment 1 Deployment 2
ES Version 9.3.0 8.13.1
Deployment Docker container Bare metal DEB on Ubuntu 22.04
Result All features unlocked All features unlocked

I guess now, I don’t need to worry about things expiring in 30 days…

Why This Doesn’t Matter

shrek-bonkI think cracking licensing models is fun, but the proposition of an enterprise license has more value in the support, SLAs, and partnership that comes with it. I just did it cause I was naturally bored lol

Prior Art

All existing public work on Elasticsearch license bypass patches the Java bytecode to disable verification entirely, thus making LicenseVerifier return true unconditionally. This includes projects like crack-elasticsearch-by-docker (ES 7.x-8.x) and ELKrack (ES 9.x), plus various CSDN/cnblogs posts covering ES 6.x-8.x.

This research takes a different approach: instead of disabling verification, we replace the trust anchor so that the verification logic works perfectly as it just trusts us instead of Elastic. No code patching, no decompilation, no repackaging of JARs with modified classes. The verification system is fully intact and doing its job. It’s just been told to trust a different signer.

Outros