saltstack / saltstack/salt

3008.x: RequestRouter builds a fresh Crypticle per AES request, driving pymalloc arena churn in MWorkerQueue

Open
#69,926 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
15.7k
Forks
5.6k
Avg merge
2d 44m
Merged PRs (30d)
80

Description

Description

On 3008.x with the default pooled request path (worker_pools_enabled: True), the MWorkerQueue process runs a Python-level dispatch loop that calls RequestRouter._extract_command on every request. For AES-encrypted payloads (the common case: minion _return, _pillar, _mine_get, _register_resources) it builds a fresh Crypticle per request:

  • salt/master.py:1668-1674
    if enc == "aes":
        key = self.secrets.get("aes", {}).get("secret", {}).value
        if key:
            import salt.crypt
            crypticle = salt.crypt.Crypticle(self.opts, key)  # every request
            load = crypticle.decrypt(load)
    
  • salt/crypt.py:2005-2008
    @classmethod
    def extract_keys(cls, key_string, key_size):
        key = salt.utils.stringutils.to_bytes(base64.b64decode(key_string))
        assert len(key) == key_size / 8 + cls.SIG_SIZE, "invalid key"
        return key[: -cls.SIG_SIZE], key[-cls.SIG_SIZE :]
    

Every AES request triggers Crypticle.__init__ -> extract_keys -> base64.b64decode(...) (fresh bytes) plus AES/HMAC key derivation. Under sustained ~10-50 req/s (typical busy master with minion returns) this drives pymalloc arena high-water climb; extrapolated to 200-350 MB / 4h RSS growth in MWorkerQueue.

Impact

Same stress rig used for #69920 (3 worker pools, flood_events + state.apply + state.highstate + salt-api curl loop). The suspect was identified during the leak audit under agents/reports/zmq-master-app-leak-audit.md (top-ranked suspect, estimated 200-350 MB of the ~380 MB gap).

Root cause

RequestRouter._extract_command needs the AES key only to inspect load["cmd"] for pool routing. The Crypticle is discarded immediately after decrypt, so every request re-derives AES/HMAC subkeys from the same shared-memory secret. SMaster.secrets["aes"]["secret"] is a multiprocessing.Array(ctypes.c_char, ...) shared across master processes; the value changes only on key rotation (SMaster.rotate_secrets), which mutates the shared buffer in place (.value = new_bytes).

Note: multiprocessing.Array.value returns a fresh bytes object on every read, so caching by id(secret.value) would rebuild on every request and defeat the purpose. Caching must key on the bytes value itself.

Proposed fix

Cache the Crypticle on the RequestRouter instance, keyed by the current AES key bytes; rebuild only when the shared-memory value changes (i.e. on rotation). Bytes comparison per request is ~200x cheaper than full Crypticle construction.

# salt/master.py:RequestRouter
def __init__(self, opts, secrets=None):
    ...
    self._crypticle = None
    self._crypticle_key_bytes = None  # last key bytes we cached against

def _get_crypticle(self):
    secret = self.secrets.get("aes", {}).get("secret")
    key_bytes = getattr(secret, "value", None)
    if not key_bytes:
        return None
    if self._crypticle_key_bytes != key_bytes:
        import salt.crypt
        self._crypticle = salt.crypt.Crypticle(self.opts, key_bytes)
        self._crypticle_key_bytes = key_bytes
    return self._crypticle

Rotation via SMaster.rotate_secrets mutates the shared buffer, next .value read observes the new bytes, and the cache rebuilds transparently. Per-request cost is one bytes comparison; the AES-CBC + HMAC key derivation runs once per key generation, not once per request.

This is the belt-and-braces companion to a separate short-circuit that skips _extract_command decrypt entirely on single-pool deployments (where cmd_to_pool is empty and everything lands in the catchall). The Crypticle cache handles the multi-pool case where decryption is genuinely needed on every request.

Reproduction

Stress rig at tests/monitoring/ (already checked in); allocations attributable to Crypticle.__init__ / extract_keys visible under tracemalloc.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in salt/master.py at RequestRouter._extract_command and the Crypticle construction path, then run the stress rig in tests/monitoring with tracemalloc. Verify that repeated AES requests reuse the cached object, that key rotation causes it to refresh, and that the reported allocation growth is reduced without changing pool routing.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, cryptography, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.