Skip to content

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

@Asteriska001

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions