F-2026-0010·weak-cryptography

Consider replacing AES-256-CTR with AES-256-GCM

Acknowledgedpentestbackendapigithub.com/bloom-art/api
TL;DR

EncryptionService uses AES-256-CTR with an IV deterministically derived from the salt (MD5(salt)). Two secrets encrypted under the same key+salt yield the XOR of plaintexts, enabling secret recovery with DB read access. Accepted post-V2 migration.

Severity
LOW
Impact
MEDIUM
Likelihood
LOW
Method
MManual review
CAT.
Complexity
HIGH
Exploitability
LOW
02Section · Description

Description

The EncryptionService uses AES-256-CTR mode with an initialization vector (IV) derived deterministically from the salt parameter via MD5(salt).

typescript
const encryptionMethod = "aes-256-ctr"; // L6, CTR mode requires unique IV per encryption
public encryptString(secretValue: string, salt: string | null, keyName: keyof EncryptionKeys): string {
const saltBuffer = this.getHashedSaltBuffer(salt ?? keyName);
// L24, IV derived from salt; if null, falls back to keyName string
const key = this.encryptionKey[keyName];
const cipher = createCipheriv(encryptionMethod, key, saltBuffer);
// L27, deterministic IV reused across calls with same salt+key
return Buffer.concat([cipher.update(secretValue), cipher.final()]).toString("hex");
}
private getHashedSaltBuffer(salt: string): Buffer {
return createHash("md5").update(salt).digest();
// L33, MD5 of salt = 16 bytes = AES-CTR IV. Same input always yields same IV.
}

The call site at partner.service.ts#L156-L165 encrypts two different secrets (passphrase and secret) for the same partner with the identical salt (partnerId) and key (polymarketBuilderEncryptionKey):

typescript
polymarketBuilderApiPassphraseEncrypted: this.encryptionService.encryptString(
builder.passphrase,
partnerId, // L158, salt = partnerId
"polymarketBuilderEncryptionKey", // L159, same key name
),
polymarketBuilderApiSecretEncrypted: this.encryptionService.encryptString(
builder.secret,
partnerId, // L163, salt = partnerId (identical to above)
"polymarketBuilderEncryptionKey", // L164, same key name (identical to above)
),

Vulnerable scenario:

  1. Both builder.passphrase and builder.secret are encrypted under the same AES key with IV = MD5(partnerId).
  2. AES-CTR with a reused key+IV pair means ciphertext_A XOR ciphertext_B = plaintext_A XOR plaintext_B.
  3. An attacker with potential database read access (SQL injection, backup leak, compromised cloud IAM) XORs the two ciphertexts.
  4. If one plaintext is known or partially guessable, the other is fully recoverable.
  5. This pattern extends to every EncryptionKeys member used with the same salt, faker private keys, oracle signing keys, Solana authority keys, trading bot keys, etc.
03Section · Impact

Impact

While the attack complexity is evidently considerable, a potential attacker with database read access can decrypt all secrets without needing the encryption keys themselves.

04Section · Recommendation

Recommendation

Replace AES-256-CTR with AES-256-GCM (authenticated encryption). Generate a cryptographically random 96-bit IV per encryption operation and prepend it to the ciphertext. Never derive IVs deterministically from predictable inputs.

05Section · Resolution

Resolution

Accepted by team, the specific reuse pattern is gone after the Polymarket V2 migration removed per-partner builder credentials, and remaining EncryptionService consumers each use a distinct keyName with a single secret per (salt, keyName) pair.

F-2026-0010

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx