Somnia Disclosed Report

Audit report Somnia Audit Contest

WebSocket client frames are never masked

Company
Created date
Sep 15 2025

Target

https://github.com/hackenproof-public/somnia

Vulnerability Details

Summary

The Somnia WebSocket client implementation violates RFC 6455 by never masking frames sent to servers. websocket_serialisation.cc:49 The masking is globally disabled via kUseMask = false, and the client handshake uses a fixed Sec-WebSocket-Key instead of generating fresh nonces. This vulnerability has Medium severity as it can cause interoperability failures with RFC-compliant servers and middleboxes, potentially leading to peer-to-peer network disruptions.

Finding Description

The vulnerability exists in the WebSocket serialization implementation where masking is controlled by a global constant kUseMask = false that disables masking for all frame types. websocket_serialisation.cc:49 The SerialiseFrame() function only applies masking when this flag is true, but since it's hardcoded to false, client frames are never masked. websocket_serialisation.cc:284-294

The WebSocket client is instantiated with is_server{false} in the client wrapper, websocket_client.h:36 but this parameter is not used to control masking behavior. The masking logic exists in the serialization code but is gated behind the disabled flag. websocket_serialisation.cc:326-331

Additionally, the client handshake uses a fixed Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== (the RFC example value) instead of generating a fresh 16-byte nonce per connection. websocket_serialisation.cc:69 The close frame implementation also bypasses the normal serializer and uses a hardcoded buffer with an all-zero masking key. websocket_serialisation.cc:100

Impact Explanation

RFC 6455 mandates that all client-to-server frames must be masked to prevent cache poisoning attacks through intermediary proxies. Standards-compliant servers, proxies, and load balancers may reject or drop unmasked client frames, leading to silent message loss or complete connection failures. In Somnia's peer-to-peer network context, this can prevent nodes from exchanging transactions and blocks across certain network configurations, potentially causing mempool inconsistencies and consensus divergence.

Likelihood Explanation

The likelihood is medium as it depends on the network infrastructure between Somnia nodes. While some servers and middleboxes may be lenient with RFC compliance, many enterprise-grade proxies and security appliances strictly enforce WebSocket standards. The vulnerability becomes more likely in production deployments where nodes communicate through corporate networks or cloud load balancers that implement RFC-compliant filtering.

Recommendation

Consider implementing role-based masking where clients mask frames but servers do not. One approach would be to modify the SerialiseFrame() function to use the is_server parameter:

void WebsocketSerialisation::SerialiseFrame(int frame_type, ByteSpanConst message,
                                            std::vector<std::uint8_t>& output) {
  // Use role-based masking: clients must mask, servers must not
  bool use_mask = !is_server;
  
  // Generate fresh masking key per frame for clients
  std::uint8_t masking_key[4];
  if (use_mask) {
    // Generate cryptographically random masking key
    GenerateRandomBytes(masking_key, 4);
  }
  
  // Apply masking logic using use_mask instead of kUseMask
  // ... rest of frame serialization with dynamic masking
}

Additionally, modify SerialiseClientHandshakeRequest() to generate a fresh Base64-encoded 16-byte nonce for each connection instead of using the fixed RFC example value. Route control frames like close/ping/pong through the normal serializer to ensure consistent masking behavior.

Notes

The WebSocket implementation correctly handles receiving masked frames from peers, websocket_serialisation.cc:218-227 so the issue is specifically with outbound frame masking. The masking infrastructure is already present in the codebase but disabled, making the fix relatively straightforward without requiring architectural changes to the transport layer.

Validation steps

Paste in somnia/transport/BUILD

cc_test(
    name = "websocket_client_masking_poc_test",
    srcs = ["websocket_client_masking_poc_test.cc"],
    linkstatic = True,
    deps = [
        ":transport",
        "//somnia/test:test_main",
    ],
)

Paste in somnia/transport/websocket_client_masking_poc_test.cc

#include <gtest/gtest.h>
#include <vector>
#include <string>

// Expose private methods for testing
#define private public
#include "somnia/transport/websocket_serialisation.h"
#undef private

#include "somnia/lib/common.h"

using somnia::ByteSpanConst;
using somnia::StringToBytes;

struct TraceEntry {
  std::string function;
  std::string parameters;
};

TEST(WebsocketClientMaskingPoCTest, ClientFramesAreUnmaskedAndKeyIsFixed) {
  std::vector<TraceEntry> trace;
  somnia::ws::WebsocketSerialisation serialiser{/*is_server=*/false};

  // Trace and capture handshake
  std::vector<std::uint8_t> handshake_bytes;
  trace.push_back({"SerialiseClientHandshakeRequest", "ip=127.0.0.1 port=80 path=/"});
  serialiser.SerialiseClientHandshakeRequest(
      "127.0.0.1", 80, "/",
      [&](https://dashboard.hackenproof.com/redirect?url=ByteSpanConst bytes) { handshake_bytes.insert(handshake_bytes.end(), bytes.begin(), bytes.end()); });
  trace.push_back({"SerialiseClientHandshakeRequest_return", ""});

  // Confirm handshake uses RFC example nonce rather than random
  std::string handshake_str(reinterpret_cast<const char*>(handshake_bytes.data()), handshake_bytes.size());
  ASSERT_NE(handshake_str.find("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="), std::string::npos);

  // Trace and capture a text frame
  std::vector<std::uint8_t> frame_bytes;
  trace.push_back({"SerialiseFrame", "frame_type=1"});
  serialiser.SerialiseFrame(1 /* text frame */, StringToBytes("hi"), frame_bytes);
  trace.push_back({"SerialiseFrame_return", ""});

  // Verify masking bit is not set on client frames
  ASSERT_GE(frame_bytes.size(), static_cast<std::size_t>(2));
  ASSERT_EQ(frame_bytes[1] & 0x80, 0) << "Expected mask bit unset";

  // Verify trace order
  ASSERT_EQ(trace.size(), 4u);
  EXPECT_EQ(trace[0].function, "SerialiseClientHandshakeRequest");
  EXPECT_EQ(trace[1].function, "SerialiseClientHandshakeRequest_return");
  EXPECT_EQ(trace[2].function, "SerialiseFrame");
  EXPECT_EQ(trace[3].function, "SerialiseFrame_return");
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Low
Bounty$3,418
Visibilitypartially
VulnerabilityBlockchain
Participants (3)
company admin
author
triage team