{"id":"GHSA-hwqm-qvj9-4jr2","aliases":["GO-2026-4760"],"url":"https://o3.security/vulnerability/GHSA-hwqm-qvj9-4jr2","summary":"gosaml2 CBC Padding Panic — Unauthenticated Process Crash","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's public RSA key (published in SAML metadata) to construct the payload — 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`** — `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\")      // <-- empties the slice if plaintext is all zeros\n\n    // Calculate index to remove based on padding\n    padLength := data[len(data)-1]             // <-- 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's public RSA certificate (published in SAML metadata) |\n| **Signature Required** | No — decryption happens before assertion signature validation |\n| **Payload Size** | Single HTTP POST (~2 KB) |\n| **Repeatability** | Unlimited — 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 — the victim |\n| `poc.py` | Attacker script — 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 — Unauthenticated Process Crash\n  Target: gosaml2 (github.com/russellhaering/gosaml2)\n  File:   types/encrypted_assertion.go:77\n  Impact: Remote DoS — 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: <hex>\n[+] RSA-OAEP encrypted AES key (256 bytes)\n[+] AES-128-CBC ciphertext: IV(<hex>) + 16 bytes\n[*] Plaintext is all zeros — 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 — 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 — 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 < 1 || padLength > k.BlockSize() || padLength > 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 < 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 — 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.","published":"2026-03-18T20:19:11Z","modified":"2026-09-10T03:50:39.554016430Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/russellhaering/gosaml2","fixedVersion":"0.11.0"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/russellhaering/gosaml2/security/advisories/GHSA-hwqm-qvj9-4jr2"},{"type":"PACKAGE","url":"https://github.com/russellhaering/gosaml2"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T03:50:39.554016430Z"}}