GHSA-HWQM-QVJ9-4JR2
Vulnerability from github – Published: 2026-03-18 20:19 – Updated: 2026-03-18 20:19Summary
The AES-CBC decryption path in DecryptBytes() panics on crafted ciphertext whose plaintext is all zero bytes. After decryption, bytes.TrimRight(data, "\x00") empties the slice, then data[len(data)-1] panics with index out of range [-1]. There is no recover() in the library. The panic propagates through ValidateEncodedResponse and kills the goroutine (or the entire process in non-net/http servers). An attacker needs only the SP's public RSA key (published in SAML metadata) to construct the payload — no valid signature is required.
Affected Version
All versions of github.com/russellhaering/gosaml2 through latest (v0.9.0 and HEAD) that support AES-CBC encrypted assertions.
Vulnerable Code
types/encrypted_assertion.go:65-79 — DecryptBytes, AES-CBC branch:
case MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:
if len(data)%k.BlockSize() != 0 {
return nil, fmt.Errorf("encrypted data is not a multiple of the expected CBC block size %d: actual size %d", k.BlockSize(), len(data))
}
nonce, data := data[:k.BlockSize()], data[k.BlockSize():]
c := cipher.NewCBCDecrypter(k, nonce)
c.CryptBlocks(data, data)
// Remove zero bytes
data = bytes.TrimRight(data, "\x00") // <-- empties the slice if plaintext is all zeros
// Calculate index to remove based on padding
padLength := data[len(data)-1] // <-- PANIC: index out of range [-1]
lastGoodIndex := len(data) - int(padLength)
return data[:lastGoodIndex], nil
Attack Details
| Property | Value |
|---|---|
| Attack Vector | Network (unauthenticated HTTP POST to ACS endpoint) |
| Authentication Required | None |
| Attacker Knowledge | SP's public RSA certificate (published in SAML metadata) |
| Signature Required | No — decryption happens before assertion signature validation |
| Payload Size | Single HTTP POST (~2 KB) |
| Repeatability | Unlimited — attacker can send the payload repeatedly |
| Affected Configurations | Any SP with SPKeyStore configured (encrypted assertion support) |
| Trigger Condition | AES-CBC plaintext that is all 0x00 bytes after decryption |
Impact
- Process crash: In gRPC servers, custom frameworks, CLI tools, and background workers, the unrecovered panic kills the entire OS process immediately.
- Goroutine crash: In
net/httpservers, the built-in per-goroutine recovery catches the panic, returning HTTP 500 and logging the full stack trace. The server survives but the request-handling goroutine is terminated abnormally. - Denial of service: The attack is unauthenticated and repeatable. A single crafted HTTP request is sufficient. Automated retries can keep the service down indefinitely.
- No valid signature needed: The SAML Response does not need to be signed. On the unsigned-response code path (
decode_response.go:346),decryptAssertions()is called before any assertion signature validation.
Reproduction
Prerequisites
- Docker (for the vulnerable server)
- Python 3.8+ with
cryptographyandrequestspackages
Files
| File | Description |
|---|---|
server.go |
Minimal SAML SP using gosaml2 — the victim |
poc.py |
Attacker script — builds and sends the crafted payload |
Dockerfile |
Multi-stage build for the vulnerable server |
run.sh |
Build and orchestration script |
Steps
# 1. Build the vulnerable server
./run.sh build
# 2. Start the server
./run.sh start
# 3. Run the attacker script
pip install cryptography requests
./run.sh attack
# Or do everything in one command:
./run.sh all
Expected Output
Attacker terminal (poc.py):
========================================================
CVE: CBC Padding Panic — Unauthenticated Process Crash
Target: gosaml2 (github.com/russellhaering/gosaml2)
File: types/encrypted_assertion.go:77
Impact: Remote DoS — single HTTP request kills process
========================================================
[*] Target: http://localhost:9999
[*] Checking server health...
[+] Server is alive
========================================================
Phase 1: Obtain SP public certificate from metadata
========================================================
[*] GET http://localhost:9999/metadata
[+] Retrieved SP certificate (xxx bytes)
========================================================
Phase 2: Build crafted EncryptedAssertion payload
========================================================
[+] Extracted RSA public key (size=2048 bits)
[*] Generated AES-128 key: <hex>
[+] RSA-OAEP encrypted AES key (256 bytes)
[+] AES-128-CBC ciphertext: IV(<hex>) + 16 bytes
[*] Plaintext is all zeros — will trigger empty-slice panic after TrimRight
[+] Built SAML Response (xxx bytes XML, xxx bytes b64)
========================================================
Phase 3: Send payload to /acs
========================================================
[*] POST http://localhost:9999/acs
[*] The server will decrypt our ciphertext, hit the all-zero
plaintext edge case, and panic in DecryptBytes()...
[*] Got HTTP 500 — goroutine panicked but net/http recovered it
========================================================
Phase 4: Verify server status
========================================================
[*] Server is still responding (net/http recovered the goroutine panic)
[*] But the panic stack trace in server logs confirms the vulnerability.
[*] In non-HTTP servers, the process would be dead.
========================================================
VULNERABILITY CONFIRMED
types/encrypted_assertion.go:77 — index out of range [-1]
Stack trace:
types/encrypted_assertion.go:77 (padLength := data[len(data)-1])
decode_response.go:176 (decryptAssertions)
decode_response.go:346 (ValidateEncodedResponse)
========================================================
Server logs (panic stack trace):
http: panic serving 127.0.0.1:xxxxx: runtime error: index out of range [-1]
goroutine XX [running]:
net/http.(*conn).serve.func1()
/usr/local/go/src/net/http/server.go:1898 +0xbe
github.com/russellhaering/gosaml2/types.(*EncryptedAssertion).DecryptBytes(...)
types/encrypted_assertion.go:77 +0x...
github.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions.func1(...)
decode_response.go:176 +0x...
github.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions(...)
decode_response.go:196 +0x...
github.com/russellhaering/gosaml2.(*SAMLServiceProvider).ValidateEncodedResponse(...)
decode_response.go:346 +0x...
Suggested Fix
Replace the unsafe zero-byte trimming and unchecked index with proper PKCS#7 unpadding and bounds checks:
case MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:
if len(data)%k.BlockSize() != 0 {
return nil, fmt.Errorf("ciphertext not multiple of block size")
}
nonce, data := data[:k.BlockSize()], data[k.BlockSize():]
c := cipher.NewCBCDecrypter(k, nonce)
c.CryptBlocks(data, data)
// Validate decrypted data is non-empty
if len(data) == 0 {
return nil, fmt.Errorf("decrypted data is empty")
}
// Proper PKCS#7 unpadding with bounds checks
padLength := int(data[len(data)-1])
if padLength < 1 || padLength > k.BlockSize() || padLength > len(data) {
return nil, fmt.Errorf("invalid padding length: %d", padLength)
}
// Verify all padding bytes are consistent
for i := len(data) - padLength; i < len(data); i++ {
if data[i] != byte(padLength) {
return nil, fmt.Errorf("invalid PKCS#7 padding")
}
}
return data[:len(data)-padLength], nil
Key changes:
1. Remove bytes.TrimRight(data, "\x00") entirely — it corrupts valid PKCS#7-padded data and creates the empty-slice condition.
2. Bounds-check padLength before using it as a slice index.
3. Validate all padding bytes match (proper PKCS#7 verification).
4. Return errors instead of panicking on malformed input.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.10.0"
},
"package": {
"ecosystem": "Go",
"name": "github.com/russellhaering/gosaml2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.11.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-129"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-18T20:19:11Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\nThe AES-CBC decryption path in `DecryptBytes()` panics on crafted ciphertext whose plaintext is all zero bytes. After decryption, `bytes.TrimRight(data, \"\\x00\")` empties the slice, then `data[len(data)-1]` panics with `index out of range [-1]`. There is no `recover()` in the library. The panic propagates through `ValidateEncodedResponse` and kills the goroutine (or the entire process in non-`net/http` servers). An attacker needs only the SP\u0027s public RSA key (published in SAML metadata) to construct the payload \u2014 no valid signature is required.\n\n## Affected Version\n\nAll versions of `github.com/russellhaering/gosaml2` through latest (`v0.9.0` and HEAD) that support AES-CBC encrypted assertions.\n\n## Vulnerable Code\n\n**`types/encrypted_assertion.go:65-79`** \u2014 `DecryptBytes`, AES-CBC branch:\n\n```go\ncase MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:\n if len(data)%k.BlockSize() != 0 {\n return nil, fmt.Errorf(\"encrypted data is not a multiple of the expected CBC block size %d: actual size %d\", k.BlockSize(), len(data))\n }\n nonce, data := data[:k.BlockSize()], data[k.BlockSize():]\n c := cipher.NewCBCDecrypter(k, nonce)\n c.CryptBlocks(data, data)\n\n // Remove zero bytes\n data = bytes.TrimRight(data, \"\\x00\") // \u003c-- empties the slice if plaintext is all zeros\n\n // Calculate index to remove based on padding\n padLength := data[len(data)-1] // \u003c-- PANIC: index out of range [-1]\n lastGoodIndex := len(data) - int(padLength)\n return data[:lastGoodIndex], nil\n```\n\n## Attack Details\n\n| Property | Value |\n|---|---|\n| **Attack Vector** | Network (unauthenticated HTTP POST to ACS endpoint) |\n| **Authentication Required** | None |\n| **Attacker Knowledge** | SP\u0027s public RSA certificate (published in SAML metadata) |\n| **Signature Required** | No \u2014 decryption happens before assertion signature validation |\n| **Payload Size** | Single HTTP POST (~2 KB) |\n| **Repeatability** | Unlimited \u2014 attacker can send the payload repeatedly |\n| **Affected Configurations** | Any SP with `SPKeyStore` configured (encrypted assertion support) |\n| **Trigger Condition** | AES-CBC plaintext that is all `0x00` bytes after decryption |\n\n## Impact\n\n- **Process crash**: In gRPC servers, custom frameworks, CLI tools, and background workers, the unrecovered panic kills the entire OS process immediately.\n- **Goroutine crash**: In `net/http` servers, the built-in per-goroutine recovery catches the panic, returning HTTP 500 and logging the full stack trace. The server survives but the request-handling goroutine is terminated abnormally.\n- **Denial of service**: The attack is unauthenticated and repeatable. A single crafted HTTP request is sufficient. Automated retries can keep the service down indefinitely.\n- **No valid signature needed**: The SAML Response does not need to be signed. On the unsigned-response code path (`decode_response.go:346`), `decryptAssertions()` is called **before** any assertion signature validation.\n\n## Reproduction\n\n### Prerequisites\n\n- Docker (for the vulnerable server)\n- Python 3.8+ with `cryptography` and `requests` packages\n\n### Files\n\n| File | Description |\n|---|---|\n| `server.go` | Minimal SAML SP using gosaml2 \u2014 the victim |\n| `poc.py` | Attacker script \u2014 builds and sends the crafted payload |\n| `Dockerfile` | Multi-stage build for the vulnerable server |\n| `run.sh` | Build and orchestration script |\n\n### Steps\n\n```bash\n# 1. Build the vulnerable server\n./run.sh build\n\n# 2. Start the server\n./run.sh start\n\n# 3. Run the attacker script\npip install cryptography requests\n./run.sh attack\n\n# Or do everything in one command:\n./run.sh all\n```\n\n### Expected Output\n\n**Attacker terminal (`poc.py`):**\n\n```\n ========================================================\n CVE: CBC Padding Panic \u2014 Unauthenticated Process Crash\n Target: gosaml2 (github.com/russellhaering/gosaml2)\n File: types/encrypted_assertion.go:77\n Impact: Remote DoS \u2014 single HTTP request kills process\n ========================================================\n\n[*] Target: http://localhost:9999\n[*] Checking server health...\n[+] Server is alive\n\n========================================================\n Phase 1: Obtain SP public certificate from metadata\n========================================================\n[*] GET http://localhost:9999/metadata\n[+] Retrieved SP certificate (xxx bytes)\n\n========================================================\n Phase 2: Build crafted EncryptedAssertion payload\n========================================================\n[+] Extracted RSA public key (size=2048 bits)\n[*] Generated AES-128 key: \u003chex\u003e\n[+] RSA-OAEP encrypted AES key (256 bytes)\n[+] AES-128-CBC ciphertext: IV(\u003chex\u003e) + 16 bytes\n[*] Plaintext is all zeros \u2014 will trigger empty-slice panic after TrimRight\n[+] Built SAML Response (xxx bytes XML, xxx bytes b64)\n\n========================================================\n Phase 3: Send payload to /acs\n========================================================\n[*] POST http://localhost:9999/acs\n[*] The server will decrypt our ciphertext, hit the all-zero\n plaintext edge case, and panic in DecryptBytes()...\n\n[*] Got HTTP 500 \u2014 goroutine panicked but net/http recovered it\n\n========================================================\n Phase 4: Verify server status\n========================================================\n[*] Server is still responding (net/http recovered the goroutine panic)\n[*] But the panic stack trace in server logs confirms the vulnerability.\n[*] In non-HTTP servers, the process would be dead.\n\n========================================================\n VULNERABILITY CONFIRMED\n types/encrypted_assertion.go:77 \u2014 index out of range [-1]\n\n Stack trace:\n types/encrypted_assertion.go:77 (padLength := data[len(data)-1])\n decode_response.go:176 (decryptAssertions)\n decode_response.go:346 (ValidateEncodedResponse)\n========================================================\n```\n\n**Server logs (panic stack trace):**\n\n```\nhttp: panic serving 127.0.0.1:xxxxx: runtime error: index out of range [-1]\ngoroutine XX [running]:\nnet/http.(*conn).serve.func1()\n /usr/local/go/src/net/http/server.go:1898 +0xbe\ngithub.com/russellhaering/gosaml2/types.(*EncryptedAssertion).DecryptBytes(...)\n types/encrypted_assertion.go:77 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions.func1(...)\n decode_response.go:176 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).decryptAssertions(...)\n decode_response.go:196 +0x...\ngithub.com/russellhaering/gosaml2.(*SAMLServiceProvider).ValidateEncodedResponse(...)\n decode_response.go:346 +0x...\n```\n\n## Suggested Fix\n\nReplace the unsafe zero-byte trimming and unchecked index with proper PKCS#7 unpadding and bounds checks:\n\n```go\ncase MethodAES128CBC, MethodAES256CBC, MethodTripleDESCBC:\n if len(data)%k.BlockSize() != 0 {\n return nil, fmt.Errorf(\"ciphertext not multiple of block size\")\n }\n nonce, data := data[:k.BlockSize()], data[k.BlockSize():]\n c := cipher.NewCBCDecrypter(k, nonce)\n c.CryptBlocks(data, data)\n\n // Validate decrypted data is non-empty\n if len(data) == 0 {\n return nil, fmt.Errorf(\"decrypted data is empty\")\n }\n\n // Proper PKCS#7 unpadding with bounds checks\n padLength := int(data[len(data)-1])\n if padLength \u003c 1 || padLength \u003e k.BlockSize() || padLength \u003e len(data) {\n return nil, fmt.Errorf(\"invalid padding length: %d\", padLength)\n }\n\n // Verify all padding bytes are consistent\n for i := len(data) - padLength; i \u003c len(data); i++ {\n if data[i] != byte(padLength) {\n return nil, fmt.Errorf(\"invalid PKCS#7 padding\")\n }\n }\n\n return data[:len(data)-padLength], nil\n```\n\nKey changes:\n1. **Remove `bytes.TrimRight(data, \"\\x00\")`** entirely \u2014 it corrupts valid PKCS#7-padded data and creates the empty-slice condition.\n2. **Bounds-check `padLength`** before using it as a slice index.\n3. **Validate all padding bytes** match (proper PKCS#7 verification).\n4. **Return errors** instead of panicking on malformed input.",
"id": "GHSA-hwqm-qvj9-4jr2",
"modified": "2026-03-18T20:19:11Z",
"published": "2026-03-18T20:19:11Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/russellhaering/gosaml2/security/advisories/GHSA-hwqm-qvj9-4jr2"
},
{
"type": "PACKAGE",
"url": "https://github.com/russellhaering/gosaml2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "gosaml2 CBC Padding Panic \u2014 Unauthenticated Process Crash"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.