Skip to content

Security: SpEL injection RCE via gray-decision infos values (ConfigurationUtils.normalize uses StandardEvaluationContext with BeanFactoryResolver) #75

Description

@Asteriska001

Summary

ConfigurationUtils.normalize(...) parses each value of a gray-decision's configuration Map as a
Spring Expression Language (SpEL) template and evaluates it in an unrestricted
StandardEvaluationContext with a BeanFactoryResolver attached. By creating an arbitrary gray
decision whose infos map contains a #{...} SpEL payload as a value, a subsequent inter-service
call triggers the policy load path, which in turn calls ConfigurationUtils.normalize on the
attacker-controlled values — leading to Remote Code Execution on the consumer service. This was
verified end-to-end against a sample setup (service-a + service-b).

Affected version / component

  • spring-cloud-gray D.0.0.2
  • Sink: spring-cloud-gray-utilscn.springcloud.gray.utils.ConfigurationUtils
  • Trigger path: DefaultGrayDecisionFactoryKeeper.getGrayDecision(...)ConfigurationUtils.normalize(infos, parser, beanFactory) (invoked on every load of decision policies)

Root cause (code excerpt)

// spring-cloud-gray-utils/.../ConfigurationUtils.java

public static Map<String, Object> normalize(Map<String, String> args,
                                            SpelExpressionParser parser,
                                            BeanFactory beanFactory) {            // L28
    Map<String, Object> map = new HashMap<>();
    for (Map.Entry<String, String> entry : args.entrySet()) {
        Object value = getValue(parser, beanFactory, entry.getValue());           // L34  every value normalized
        map.put(entry.getKey(), value);
    }
    return map;
}

private static Object getValue(SpelExpressionParser parser, BeanFactory beanFactory,
                               String entryValue) {                                // L74
    Object value;
    String rawValue = entryValue;
    if (rawValue != null) rawValue = rawValue.trim();
    if (rawValue != null && rawValue.startsWith("#{") && entryValue.endsWith("}")) { // L81  treat as SpEL template
        StandardEvaluationContext context = new StandardEvaluationContext();        // L83  no sandbox
        if (Objects.nonNull(beanFactory)) {
            context.setBeanResolver(new BeanFactoryResolver(beanFactory));          // L85  ★ @bean lookups enabled
        }
        Expression expression = parser.parseExpression(entryValue,                  // L87
                new TemplateParserContext());                                       // L88
        value = expression.getValue(context);                                       // L90  RCE
    } else {
        value = entryValue;
    }
    return value;
}

Untrusted decision-config Map<String,String> values are passed straight to
parseExpression(..., TemplateParserContext).getValue(StandardEvaluationContext) — full SpEL
capability: type references (T(...)), constructors, arbitrary method calls. The added
BeanFactoryResolver even lets the expression reach into the Spring BeanFactory via @beanName
syntax, which is an additional class of compromise (e.g. tampering with service beans / pivoting
within the JVM).

Verified exploit chain (per analysis)

  1. Plant — create an arbitrary gray decision whose infos map contains, for any key, a value
    that begins with #{ and ends with } (a SpEL template) and embeds the payload. The decision is
    saved into PolicyDecisionManager. No evaluation yet — the value is just stored.
  2. Wire — register a producer service-a and a consumer service-b, attaching the planted
    policy.
  3. Trigger — send any request to the consumer that causes it to call the producer, e.g.
    curl 127.0.0.1:20102/api/test/restTemplateGet
    
    The consumer evaluates whether to apply gray routing for service-a. To do that it loads all
    decision policies from PolicyDecisionManager, which calls
    DefaultGrayDecisionFactoryKeeper.getGrayDecision(...). That call hits
    ConfigurationUtils.normalize(infos, parser, beanFactory) — every #{...} value is parsed and
    evaluated, and the planted payload executes on the consumer service host (RCE).
    The downstream "should we route this request to gray?" check is moot — the code has already run
    during the policy-load step.

Proof of concept (non-destructive)

Use a benign SpEL payload (e.g. a value that invokes T(java.lang.Runtime).getRuntime().exec('calc')
to launch calc as a harmless placeholder) as one of the infos values when creating the gray
decision. A real attacker would substitute an arbitrary command.

Impact

Remote Code Execution on any consumer service that loads gray-decision policies — i.e. any service
that participates in gray routing. The exact privilege required is "ability to create/modify a gray
decision" (i.e. influence the persisted infos map). Because BeanFactoryResolver is registered on
the evaluation context, the attacker also gets access to Spring beans inside the process.

  • Tentative severity: Critical (RCE) — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H. The PR
    estimate depends on who is authorized to write gray decisions in a given deployment.

Suggested remediation

  • Do not evaluate decision-config strings as SpEL by default. The #{...} template feature is
    surprising for what looks like configuration data.
  • If template substitution must be kept, use a restricted SimpleEvaluationContext (no T(...), no
    constructors, no arbitrary method calls; only the property accessors actually needed). Do not
    attach a BeanFactoryResolver.
  • Alternatively, validate values on write (reject #{...} constructs unless an explicit trusted
    template flag is set).

Notes for triage

  • The chain was verified end-to-end against a sample setup (service-a + service-b), curl request
    as above. Happy to share additional reproduction details privately if useful.
  • I have not disclosed this publicly elsewhere.

Thanks for triaging.

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