#3709·CopyQ

Local IPC CommandFunctionCall argument count unchecked causes stack out-of-bounds write

Author: router0mailCreated Sep 18, 2026Updated Sep 18, 2026

Summary

CopyQ's local IPC protocol lets any connected client invoke ScriptableProxy methods via a CommandFunctionCall message. The message payload deserializes a QVector of call arguments whose element count is fully attacker-controlled. This count is used, unchecked, as the loop bound writing into a fixed 9-element stack array (QGenericArgument args[9], sized for Qt's QMetaMethod::invoke argument limit). Supplying more than 9 arguments -- including simple default-constructed/invalid QVariant() filler values, which collide with the framework's own "unknown/void parameter" sentinel and are accepted by the loop's type-matching branch -- writes past the end of the stack array for every extra argument. The wire protocol needed to trigger this is fully public: two hardcoded constants and standard QDataStream encoding, no reverse-engineering required.

Root cause

src/scriptable/scriptableproxy.cpp:707-740 (write at lines 730/732, sinks at 747/759):

cpp
QVector<QVariant> arguments;
stream >> arguments;   // element count fully attacker-controlled over the wire

QGenericArgument args[9];   // fixed-size stack array, sized for Qt's 9-arg invoke limit

for (int i = 0; i < arguments.size(); ++i) {   // no check arguments.size() <= 9
    // metaMethod.parameterType(i) returns 0 (UnknownType) for i beyond the
    // target slot's real parameter count
    // a default-constructed QVariant() also has userType() == 0
    // -> the two zeros collide, taking the "else if" branch regardless of i
    args[i] = QGenericArgument(...);   // OOB write once i >= 9
}

The wire message requires only two hardcoded, publicly-known constants (serializedFunctionCallMagicNumber = 0x58746908, serializedFunctionCallVersion = 2) plus standard QDataStream encoding of QVector. Q_ASSERT(false) calls present elsewhere in this function are compiled out in release builds, so nothing stops the OOB write in production.

Proof of concept (code-trace)

Connect to CopyQ's local socket. Send a message with messageCode = CommandFunctionCall, payload a QDataStream (Qt_6_2) containing: magic 0x58746908, version 2, any functionCallId (int), the name of a valid existing ScriptableProxy slot (e.g. "tab(QString)"), then a QVector with e.g. 30 elements, mostly default-constructed invalid QVariant(). Wrap with the client-socket wire framing (protocolMagicNumber = 0x0C090701, protocolVersion = 1, length-prefixed). Expected: stack corruption in the CopyQ server process, detectable as a stack-buffer-overflow under ASAN. Not yet compiled/run.

Impact

Any local process able to connect to CopyQ's local IPC socket -- the same channel every copyq CLI invocation and third-party script/integration uses -- can crash or potentially corrupt the CopyQ server process's stack with a single crafted message. Reachability is bounded by local socket access control (typically same-user, depends on deployment/sandboxing).

Suggested fix

Validate arguments.size() <= metaMethod.parameterCount() (or at minimum <= 9) immediately after deserializing arguments and before the dispatch loop; reject/close the connection if out of range. Consider a bounds-checked container as defense-in-depth.


This report was produced with AI assistance (Claude, Anthropic) via source-code trace of CopyQ's local IPC RPC-dispatch path, working out the precise type-collision condition that makes the OOB write trigger even with simple filler arguments. Not compiled or run -- code-trace only. A human should verify with an ASAN build before relying on this for a patch decision.