MCP Server Security · Covert Channels
MCP server covert channel security — timing covert channels, storage covert channels, and cross-session information leakage in MCP servers
A covert channel is a communication path between processes or sessions that was not designed for information transfer, and that bypasses all access control checks. In MCP servers, covert channels arise from shared infrastructure — caches, connection pools, rate limit counters, and processing queues — that is visible across session boundaries. A malicious session can encode a query into observable behavior (latency, cache hit/miss) and a cooperating or victim session can read the answer, transmitting information without any data access violation that access control would prevent.
Covert channel taxonomy in MCP server deployments
| Channel type | Mechanism | Bandwidth | MCP example |
|---|---|---|---|
| Timing channel | Encode data in timing of operations (fast = cache hit / 0; slow = cache miss / 1) | Low (bits/sec) | Attacker measures latency of queryUser(id) calls to determine which user IDs are cached (i.e., recently accessed by another session) |
| Storage channel | Encode data by setting/unsetting shared state (e.g., populate or evict cache entries) | Medium | Attacker fills cache entries for specific keys; victim session infers which keys are populated by measuring eviction behavior |
| Resource contention channel | Encode data via CPU/memory/I/O contention patterns visible to other processes | Low–medium | High-load tool calls in one session cause measurable latency increase in concurrent sessions on the same host |
| Error timing channel | Different error paths execute different code → different timings reveal branching decisions | Medium | Authentication tool: userExists(email) returns faster for non-existent users (early return) than existing users (password hash comparison) — user enumeration via timing |
Timing covert channel: cache-timing user enumeration
The most exploitable timing channel in MCP servers is cache-timing in tool calls that look up resources by identifier. When a cached lookup is faster than an uncached one, an attacker can determine whether a resource was recently accessed — revealing activity patterns from other sessions.
// Vulnerable: timing leak via cache hit/miss differential
async function queryUser(userId) {
const cached = cache.get(`user:${userId}`);
if (cached) return cached; // Fast path: ~1ms
const user = await db.query( // Slow path: ~50ms database round-trip
'SELECT * FROM users WHERE id = ?', [userId]
);
cache.set(`user:${userId}`, user, 300);
return user;
}
// Attacker measures latency of queryUser(id) calls:
// ~1ms → user was accessed by another session in the last 5 minutes
// ~50ms → user not recently accessed
// Defense: constant-time cache with noise injection
async function queryUserConstantTime(userId) {
const startTime = performance.now();
const cached = cache.get(`user:${userId}`);
let user;
if (cached) {
user = cached;
// Simulate database latency so cache hits are not measurable
const elapsed = performance.now() - startTime;
const noise = Math.random() * 10; // 0–10ms noise
await sleep(Math.max(0, 50 - elapsed + noise)); // pad to ~50ms
} else {
user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
cache.set(`user:${userId}`, user, 300);
}
return user;
}
// For authentication: always run the full password hash comparison
// even when the user doesn't exist (use a dummy hash)
async function authenticate(email, password) {
const DUMMY_HASH = '$argon2id$v=19$m=65536,t=3,p=4$dummyhashvalue$';
const user = await getUserByEmail(email); // Always runs
const hash = user ? user.passwordHash : DUMMY_HASH;
const valid = await argon2.verify(hash, password); // Always runs full comparison
if (!user || !valid) throw new Error('INVALID_CREDENTIALS');
return user;
}
Storage covert channel: cache poisoning for cross-session signaling
A storage covert channel uses a shared mutable resource — a cache, a rate limit counter, a queue depth — as a communication medium. An attacker session writes a signal by populating specific cache entries; a cooperating session reads the signal by testing whether those entries are populated.
// Attack: attacker session signals "true" by populating key cache:user:TARGET_ID
// Victim session (or cooperating session) infers signal by measuring query latency
// or by explicitly probing cache-dependent behavior
// Defense 1: per-tenant cache namespacing — sessions can't share cache entries
function getCacheKey(tenantId, resource, id) {
return `t:${tenantId}:${resource}:${id}`;
// A session from tenant A cannot observe or influence entries for tenant B
}
// Defense 2: cache isolation via separate Redis databases or key prefixes enforced at middleware
class TenantScopedCache {
constructor(redis, tenantId) {
this.redis = redis;
this.prefix = `tenant:${tenantId}:`;
}
get(key) { return this.redis.get(this.prefix + key); }
set(key, value, ttl) { return this.redis.setex(this.prefix + key, ttl, JSON.stringify(value)); }
del(key) { return this.redis.del(this.prefix + key); }
}
// Defense 3: for shared caches that cannot be tenant-scoped (e.g. CDN responses),
// add cryptographic nonces to cached content to prevent attacker from predicting cache keys
function makeCacheKey(resource, id, tenantId) {
const mac = crypto.createHmac('sha256', process.env.CACHE_KEY_SECRET)
.update(`${tenantId}:${resource}:${id}`)
.digest('hex')
.slice(0, 16);
return `${resource}:${mac}`;
}
Resource contention channel: rate limit oracle
Rate limit counters are shared infrastructure. If your MCP server exposes rate limit state in error responses or in observable behavior, an attacker session can deplete another session's rate limit budget as a denial of service, or use the rate limit counter as a storage channel to signal other sessions.
// Vulnerable: rate limit counters shared across sessions by IP or user ID
// An attacker can deplete the target session's budget with their own requests
// if the rate limiter uses a shared namespace
// Defense: rate limit by session ID, not by IP or user ID alone
const sessionRateLimiter = rateLimit({
keyGenerator: (req) => req.session.id, // Per-session, not per-IP
windowMs: 60_000,
max: 100,
// Don't expose remaining count in headers — this is a storage channel
standardHeaders: false,
legacyHeaders: false,
});
// Avoid exposing rate limit state that can be used as an oracle:
// X-RateLimit-Remaining: N ← this is a timing/storage channel if shared across sessions
Covert channels are hard to fully eliminate — any shared resource creates a potential channel. The goal is reducing bandwidth (how much information can leak per unit time) below a practical exploitation threshold, and ensuring cross-tenant leakage is architecturally prevented through namespacing and isolation even when timing channels remain.
SkillAudit findings for covert channel vulnerabilities in MCP servers
SkillAudit's static analysis flags non-constant-time comparison paths and cross-tenant cache key patterns in MCP server code. Run a free audit to identify covert channel risks in your deployment.