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.

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:
- Load a public key from
public.key(a file inside the x-pack-core JAR) - Rebuild the license JSON from its fields (not from the signature blob)
- 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?”

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/*.SForMETA-INF/*.RSAsignature 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.

“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.

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_nodesis serialized as a nullable Integer (null when -1)- The
signaturefield 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:
- ES starts up
- Loads
public.keyfrom the x-pack-core JAR (X.509 DER format) - Reads the license from cluster state
- Rebuilds the license JSON from its fields (spec view mode)
- Extracts the RSA signature from the signature blob
- Runs
SHA512withRSA verify(rebuilt_json, rsa_signature, public_key) - 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

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

{"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.



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
![]() | I 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
- Elasticsearch x-pack source code - LicenseVerifier.java, CryptUtils.java, License.java
- elastic_forge.py - License generation and signing tool

