[P2o Vancouver 2023] Vài dòng về SharePoint Pre-Auth RCE chain (CVE-2023–29357 & CVE-2023–24955)
Mình bắt đầu làm SharePoint ngay từ những ngày đầu join StarLabs — 04/2022. Bug đầu tiên mình tìm thấy ngay sau 2 ngày đọc code của SharePoint Server 2019, tuy nhiên phải đến tận tháng 03/2023 thì mới tìm được phần còn lại để hoàn thiện RCE chain.
Mình pick target này là do trước đó chưa từng thấy ai pwn thành công ở pwn2own cả, một phần chắc là do điều kiện khá ngặt nghèo. Sau khi setup xong, với default config của SharePoint thì gần như tất cả chức năng của web đều yêu cầu NTLM Auth, không giống như với các sharepoint portal trong thực tế, các web này được set Anonymous auth nên người dùng có thể truy cập mà không cần Auth gì cả. Việc config anonymous auth như vậy được coi là non-default config và phía ZDI không chấp nhận điều kiện này.
Thật may mắn là mình tìm ra được bug bypass auth khá sớm và phần còn lại của RCE chain cũng được tìm ra kịp deadline của ZDI.
Chain RCE này dựa vào 2 bug chính:
- Authentication bypass in
SPApplicationAuthenticationModule
- Code Injection in
DynamicProxyGenerator
Để tìm và tận dụng bug thứ nhất thì không khó, tuy nhiên có một hạn chế là bug này chỉ hoạt động trên một số entrypoint nhất định, đại diện là SharePoint API. Từ đó đòi hỏi chain thứ 2 của bug này phải được trigger qua SharePoint API, đây cũng là phần khó nhất của exploit chain.
*Long story alerted!
# Affected version
- SharePoint 2019 (version < 2023 May patch)
# Vulnerablility #1: SharePoint Application Authentication Bypass
Với cấu hình thiết lập SharePoint mặc định, hầu hết mọi requests gửi đến SharePoint Site đều cần trải qua một bước xác thực bằng NTLM.
Tuy nhiên, trong phân tích tệp cấu hình web, mình nhận ra rằng có ít nhất 4 loại xác thực khác có thể sử dụng:
Mình bắt tay vào xem chi tiết cách hoạt động của từng module này, trong đó có 2 module tiềm năng, đó là: FederatedAuthentication và SPApplicationAuthentication.
Kết quả là mình phát hiện 1 bug bypass authentication tại module SPApplicationAuthentication.
Module này register method SPApplicationAuthenticationModule.AuthenticateRequest() để handle các request authentication:
namespace Microsoft.SharePoint.IdentityModel
{
internal sealed class SPApplicationAuthenticationModule : IHttpModule
{
public void Init(HttpApplication context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}
context.AuthenticateRequest += this.AuthenticateRequest;
context.PreSendRequestHeaders += this.PreSendRequestHeaders;
}
//...
}
//...
}
Như vậy, mỗi khi gửi request HTTP đến SharePoint site, method này sẽ được gọi để xử lý logic xác thực!
Xem xét kỹ hơn method SPApplicationAuthenticationModule.AuthenticateRequest(), method ShouldTryApplicationAuthentication() sẽ được gọi để kiểm tra xem các URL nào có thể sử dụng phương thức xác thực này:
private void AuthenticateRequest(object sender, EventArgs e)
{
if (!SPApplicationAuthenticationModule.ShouldTryApplicationAuthentication(context, spfederationAuthenticationModule)) // [1]
{
spidentityReliabilityMonitorAuthenticateRequest.ExpectedFailure(TaggingUtilities.ReserveTag(18990616U), "Not an OAuthRequest.");
//...
}
else
{
bool flag = this.ConstructIClaimsPrincipalAndSetThreadIdentity(httpApplication, context, spfederationAuthenticationModule, out text); // [2]
if (flag)
{
//...
spidentityReliabilityMonitorAuthenticateRequest.Success(null);
}
else
{
//...
OAuthMetricsEventHelper.LogOAuthMetricsEvent(text, QosErrorType.ExpectedFailure, "Can't sign in using token.");
}
//...
}
//...
}
Tại [1], nếu URL chứa một trong pattern sau, nó sẽ được phép sử dụng phương thức xác thực OAuth:
/_vti_bin/client.svc
/_vti_bin/listdata.svc
/_vti_bin/sites.asmx
/_api/
/_vti_bin/ExcelRest.aspx
/_vti_bin/ExcelRest.ashx
/_vti_bin/ExcelService.asmx
/_vti_bin/PowerPivot16/UsageReporting.svc
/_vti_bin/DelveApi.ashx
/_vti_bin/DelveEmbed.ashx
/_layouts/15/getpreview.ashx
/_vti_bin/wopi.ashx
/_layouts/15/userphoto.aspx
/_layouts/15/online/handlers/SpoSuiteLinks.ashx
/_layouts/15/wopiembedframe.aspx
/_vti_bin/homeapi.ashx
/_vti_bin/publiccdn.ashx
/_vti_bin/TaxonomyInternalService.json/GetSuggestions
/_layouts/15/download.aspx
/_layouts/15/doc.aspx
/_layouts/15/WopiFrame.aspx
Một khi điều kiện trên được thỏa mãn, SPApplicationAuthenticationModule.ConstructIClaimsPrincipalAndSetThreadIdentity() sẽ được gọi tại [2] để tiếp tục xử lý authentication logic.
Nội dung cơ bản của method này như sau:
private bool ConstructIClaimsPrincipalAndSetThreadIdentity(HttpApplication httpApplication, HttpContext httpContext, SPFederationAuthenticationModule fam, out string tokenType)
{
//...
if (!this.TryExtractAndValidateToken(httpContext, out spincomingTokenContext, out spidentityProofToken)) // [3]
{
ULS.SendTraceTag(832154U, ULSCat.msoulscat_WSS_ApplicationAuthentication, ULSTraceLevel.Medium, "SPApplicationAuthenticationModule: Couldn't find a valid token in the request.");
return false;
}
//...
if (spincomingTokenContext.TokenType != SPIncomingTokenType.Loopback && httpApplication != null && !SPSecurityTokenServiceManager.LocalOrThrow.AllowOAuthOverHttp && !Uri.UriSchemeHttps.Equals(SPAlternateUrl.ContextUri.Scheme, StringComparison.OrdinalIgnoreCase)) // [4]
{
SPApplicationAuthenticationModule.SendSSLRequiredResponse(httpApplication);
}
JsonWebSecurityToken jsonWebSecurityToken = spincomingTokenContext.SecurityToken as JsonWebSecurityToken;
//...
this.SignInProofToken(httpContext, jsonWebSecurityToken, spidentityProofToken); // [5]
//...
}
Note: [4] và [5] sẽ được đề cập sau.
Tại [3], method SPApplicationAuthenticationModule.TryExtractAndValidateToken() sẽ thực hiện parse auth token từ HTTP request và validate token này:
private bool TryExtractAndValidateToken(HttpContext httpContext, out SPIncomingTokenContext tokenContext, out SPIdentityProofToken identityProofToken)
{
//...
if (!this.TryParseOAuthToken(httpContext.Request, out text)) // [6]
{
return false;
}
//...
if (VariantConfiguration.IsGlobalExpFeatureToggleEnabled(ExpFeatureId.AuthZenProofToken) && this.TryParseProofToken(httpContext.Request, out text2))
{
SPIdentityProofToken spidentityProofToken = SPIdentityProofTokenUtilities.CreateFromJsonWebToken(text2, text); // [7]
}
if (VariantConfiguration.IsGlobalExpFeatureToggleEnabled(ExpFeatureId.AuthZenProofToken) && !string.IsNullOrEmpty(text2))
{
Microsoft.IdentityModel.Tokens.SecurityTokenHandler identityProofTokenHandler = SPClaimsUtility.GetIdentityProofTokenHandler();
StringBuilder stringBuilder = new StringBuilder();
using (XmlWriter xmlWriter = XmlWriter.Create(stringBuilder))
{
identityProofTokenHandler.WriteToken(xmlWriter, spidentityProofToken); // [8]
}
SPIdentityProofToken spidentityProofToken2 = null;
using (XmlReader xmlReader = XmlReader.Create(new StringReader(stringBuilder.ToString())))
{
spidentityProofToken2 = identityProofTokenHandler.ReadToken(xmlReader) as SPIdentityProofToken;
}
ClaimsIdentityCollection claimsIdentityCollection = null;
claimsIdentityCollection = identityProofTokenHandler.ValidateToken(spidentityProofToken2); // [9]
tokenContext = new SPIncomingTokenContext(spidentityProofToken2.IdentityToken, claimsIdentityCollection);
identityProofToken = spidentityProofToken2;
tokenContext.IsProofTokenScenario = true;
SPClaimsUtility.VerifyProofTokenEndPointUrl(tokenContext); // [10]
SPIncomingServerToServerProtocolIdentityHandler.SetRequestIncomingOAuthIdentityType(tokenContext, httpContext.Request);
SPIncomingServerToServerProtocolIdentityHandler.SetRequestIncomingOAuthTokenType(tokenContext, httpContext.Request);
}
}
Tại [6], TryParseOAuthToken() sẽ lấy OAuth Access Token từ HTTP request thông qua query string access_token
hoặc qua header Authorization
, và lưu vào biến text
.
Request HTTP hợp lệ sẽ có dạng như sau:
GET /_api/web/ HTTP/1.1
Connection: close
Authorization: Bearer <access_token>
User-Agent: python-requests/2.27.1
Host: sharepoint
Tương tự, sau khi extract access_token
từ HTTP request, method TryParseProofToken() sẽ cố gắng lấy một loại token khác, gọi là proof token
từ query string prooftoken
hoặc header X-PROOF_TOKEN
, và lưu vào biến text2
Tại [7], cả 2 token này được đưa vào SPIdentityProofTokenUtilities.CreateFromJsonWebToken() để tiếp tục xử lý.
Nội dung của method SPIdentityProofTokenUtilities.CreateFromJsonWebToken() như sau:
internal static SPIdentityProofToken CreateFromJsonWebToken(string proofTokenString, string identityTokenString)
{
RequestApplicationSecurityTokenResponse.NonValidatingJsonWebSecurityTokenHandler nonValidatingJsonWebSecurityTokenHandler = new RequestApplicationSecurityTokenResponse.NonValidatingJsonWebSecurityTokenHandler();
SecurityToken securityToken = nonValidatingJsonWebSecurityTokenHandler.ReadToken(proofTokenString); // [11]
if (securityToken == null)
{
ULS.SendTraceTag(3536843U, ULSCat.msoulscat_WSS_ApplicationAuthentication, ULSTraceLevel.Unexpected, "CreateFromJsonWebToken: Proof token is not a valid JWT string.");
throw new InvalidOperationException("Proof token is not JWT");
}
SecurityToken securityToken2 = nonValidatingJsonWebSecurityTokenHandler.ReadToken(identityTokenString); // [12]
if (securityToken2 == null)
{
ULS.SendTraceTag(3536844U, ULSCat.msoulscat_WSS_ApplicationAuthentication, ULSTraceLevel.Unexpected, "CreateFromJsonWebToken: Identity token is not a valid JWT string.");
throw new InvalidOperationException("Identity token is not JWT");
}
//...
JsonWebSecurityToken jsonWebSecurityToken = securityToken2 as JsonWebSecurityToken;
if (jsonWebSecurityToken == null || !jsonWebSecurityToken.IsAnonymousIdentity())
{
spidentityProofToken = new SPIdentityProofToken(securityToken2, securityToken);
try
{
new SPAudienceValidatingIdentityProofTokenHandler().ValidateAudience(spidentityProofToken); // [13]
return spidentityProofToken;
}
//...
}
//...
}
Nhìn qua mình đoán ra là cả access_token (param identityTokenString
) và proof_token (param proofTokenString
) đều nằm ở dạng JSON Web Token (JWT).
NonValidatingJsonWebSecurityTokenHandler.ReadToken() được gọi để parse proof token tại [11].
RequestApplicationSecurityTokenResponse.NonValidatingJsonWebSecurityTokenHandler là một class con của JsonWebSecurityTokenHandler. Bởi vì NonValidatingJsonWebSecurityTokenHandler không override method ReadToken, nên tại [11] sẽ gọi tới JsonWebSecurityTokenHandler.ReadToken() và JsonWebSecurityTokenHandler.ReadTokenCore().
Các phần mã nguồn của JsonWebSecurityTokenHandler có liên quan tới việc validate token tại [11] và [12] như sau:
public virtual SecurityToken ReadToken(string token)
{
return this.ReadTokenCore(token, false);
}
public virtual bool CanReadToken(string token)
{
Utility.VerifyNonNullOrEmptyStringArgument("token", token);
return this.IsJsonWebSecurityToken(token);
}
private bool IsJsonWebSecurityToken(string token)
{
return Regex.IsMatch(token, "^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$");
}
private SecurityToken ReadTokenCore(string token, bool isActorToken)
{
Utility.VerifyNonNullOrEmptyStringArgument("token", token);
if (!this.CanReadToken(token)) // [14]
{
throw new SecurityTokenException("Unsupported security token.");
}
string[] array = token.Split(new char[] { '.' });
string text = array[0]; // JWT Header
string text2 = array[1]; // JWT Payload (JWS Claims)
string text3 = array[2]; // JWT Signature
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
dictionary.DecodeFromJson(Base64UrlEncoder.Decode(text));
Dictionary<string, string> dictionary2 = new Dictionary<string, string>(StringComparer.Ordinal);
dictionary2.DecodeFromJson(Base64UrlEncoder.Decode(text2));
string text4;
dictionary.TryGetValue("alg", out text4); // [15]
SecurityToken securityToken = null;
if (!StringComparer.Ordinal.Equals(text4, "none")) // [16]
{
if (string.IsNullOrEmpty(text3))
{
throw new SecurityTokenException("Missing signature.");
}
SecurityKeyIdentifier signingKeyIdentifier = this.GetSigningKeyIdentifier(dictionary, dictionary2);
SecurityToken securityToken2;
base.Configuration.IssuerTokenResolver.TryResolveToken(signingKeyIdentifier, out securityToken2);
if (securityToken2 == null)
{
throw new SecurityTokenException("Invalid JWT token. Could not resolve issuer token.");
}
securityToken = this.VerifySignature(string.Format(CultureInfo.InvariantCulture, "{0}.{1}", new object[] { text, text2 }), text3, text4, securityToken2);
}
//...
}
Tại [14], JsonWebSecurityTokenHandler.CanReadToken() được gọi để đảm bảo token có dạng ^[A-Za-z0–9-_]+\\.[A-Za-z0–9-_]+\\.[A-Za-z0–9-_]*$
, chính là định dạng của JWT.
Sau đó, các thành phần header, payload, signature của JWT được extract và decode base64.
Tại [15], trường alg
được extract từ phần header của JWT và lưu vào biến text4
.
Phần đầu tiên của bug bypass auth này nằm tại [16]. Khi trường alg được đặt giá trị khác none, method VerifySignature() sẽ được gọi để verify signature của JWT. Tuy nhiên, nếu alg = none, việc verify signature này sẽ hoàn toàn bị bỏ qua. Điều này cho phép mình có thể thao túng nội dung của token tùy ý.
Quay trở lại [13], SPAudienceValidatingIdentityProofTokenHandler.ValidateAudience() thực hiện việc validate trường aud
của token.
Một token hợp lệ sẽ có trường aud
như sau:
00000003-0000-0ff1-ce00-000000000000/splab@3b80be6c-6741-4135-9292-afed8df596af
Format của trường aud là <client_id>/<hostname>@<realm>
:
- Với
client_id
luôn là 00000003–0000–0ff1-ce00–000000000000 <hostname>
là IIS hostname của SharePoint server (có thể sẽ hơi khác một chút với hostname từ HTTP request)<realm>
có thể được lấy từ response headerWWW-Authenticate
Dưới đây là request HTTP được sử dụng để lấy realm:
GET /_api/web/ HTTP/1.1
Connection: close
User-Agent: python-requests/2.27.1
Host: sharepoint
Authorization: Bearer
Trong phần HTTP Response sẽ có giá trịWWW-Authenticate
cần tìm:
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
//...
WWW-Authenticate: Bearer realm="3b80be6c-6741-4135-9292-afed8df596af",client_id="00000003-0000-0ff1-ce00-000000000000",trusted_issuers="00000003-0000-0ff1-ce00-000000000000@3b80be6c-6741-4135-9292-afed8df596af"
.
Tiếp sau đó, SPIdentityProofToken sẽ được khởi tạo từ access_token và proof_token.
Tại [8], identityProofTokenHandler được trả về từ method SPClaimsUtility.GetIdentityProofTokenHandler():
internal static SecurityTokenHandler GetIdentityProofTokenHandler()
{
//...
return securityTokenHandlerCollection.Where((SecurityTokenHandler h) => h.TokenType == typeof(SPIdentityProofToken)).First<SecurityTokenHandler>();
}
Dựa vào đây ta có thể thấy identityProofTokenHandler là một instance của class SPIdentityProofTokenHandler.
Tại [9], identityProofTokenHandler.ValidateToken(spidentityProofToken2) được gọi để thực hiện validate token. Sau khi check một hồi sẽ tới được SPJsonWebSecurityBaseTokenHandler.ValidateTokenIssuer(). Nội dung của method này:
internal void ValidateTokenIssuer(JsonWebSecurityToken token)
{
bool flag = VariantConfiguration.IsGlobalExpFeatureToggleEnabled(ExpFeatureId.AuthZenHashedProofToken);
if (flag && SPIdentityProofTokenUtilities.IsHashedProofToken(token))
{
ULS.SendTraceTag(21559514U, SPJsonWebSecurityBaseTokenHandler.Category, ULSTraceLevel.Medium, "Found hashed proof tokem, skipping issuer validation.");
return;
}
//...
this.ValidateTokenIssuer(token.ActorToken.IssuerToken as X509SecurityToken, token.ActorToken.Issuer);
}
Đáng chú ý ở method này, nếu token
là dạng hashedprooftoken
thì công đoạn validate issuer sẽ bị bỏ qua. Đây chính là root cause của toàn bộ lỗ hổng này.
Để set token thành dạng hashedprooftoken
, cần thêm một trường ver
với giá trị = hashedprooftoken.
Quay trở lại [10], SPClaimsUtility.VerifyProofTokenEndPointUrl(tokenContext) được gọi để verify hash của URL hiện tại. Giá trị này được lưu tại trường endpointurl
, có thể tính toán được bằng công thức:
base64_encode(sha256(request_url))
Tuy nhiên sau đó một người em đã chỉ ra cách đơn giản hơn đó là set endpointurlLength = 1
Sau khi thực thi SPApplicationAuthenticationModule.TryExtractAndValidateToken(), chương trình tiếp tục gọi tới [4]:
private bool ConstructIClaimsPrincipalAndSetThreadIdentity(HttpApplication httpApplication, HttpContext httpContext, SPFederationAuthenticationModule fam, out string tokenType)
{
//...
if (!this.TryExtractAndValidateToken(httpContext, out spincomingTokenContext, out spidentityProofToken)) // [3]
//...
if (spincomingTokenContext.TokenType != SPIncomingTokenType.Loopback && httpApplication != null && !SPSecurityTokenServiceManager.LocalOrThrow.AllowOAuthOverHttp && !Uri.UriSchemeHttps.Equals(SPAlternateUrl.ContextUri.Scheme, StringComparison.OrdinalIgnoreCase)) // [4]
{
SPApplicationAuthenticationModule.SendSSLRequiredResponse(httpApplication);
}
JsonWebSecurityToken jsonWebSecurityToken = spincomingTokenContext.SecurityToken as JsonWebSecurityToken;
//...
this.SignInProofToken(httpContext, jsonWebSecurityToken, spidentityProofToken); // [5]
Tại đây, nếu spincomingTokenContext.TokenType khác spincomingTokenContext.Loopback và HTTP request hiện tại không được encrypt với SSL thì chương trình sẽ throw một exception. Như vậy, trường isloopback
phải được đặt giá trị = true để tránh trường hợp này xảy ra.
Sau đó tại [5], token sẽ được đưa vào SPApplicationAuthenticationModule.SignInProofToken()
private void SignInProofToken(HttpContext httpContext, JsonWebSecurityToken token, SPIdentityProofToken proofIdentityToken)
{
SecurityContext.RunAsProcess(delegate
{
Uri contextUri = SPAlternateUrl.ContextUri;
SPAuthenticationSessionAttributes? spauthenticationSessionAttributes = new SPAuthenticationSessionAttributes?(SPAuthenticationSessionAttributes.IsBrowser);
SecurityToken securityToken = SPSecurityContext.SecurityTokenForProofTokenAuthentication(proofIdentityToken.IdentityToken, proofIdentityToken.ProofToken, spauthenticationSessionAttributes);
IClaimsPrincipal claimsPrincipal = SPFederationAuthenticationModule.AuthenticateUser(securityToken);
//...
});
}
Method này sẽ tạo một instance của SecurityTokenForContext từ token đã nhận và gửi đến Security Token Service (STS) để tiến hành xác thực user.
Quá trình xác thực với STS cũng có khá nhiều thứ hay ho, tuy nhiên do bài hơi dài nên mình sẽ đề cập chi tiết ở một bài khác.
Tóm lại, dựa vào các thông tin ở trên, ta có thể build được một JWT hợp lệ như sau:
eyJhbGciOiAibm9uZSJ9.eyJpc3MiOiIwMDAwMDAwMy0wMDAwLTBmZjEtY2UwMC0wMDAwMDAwMDAwMDAiLCJhdWQiOiAgIjAwMDAwMDAzLTAwMDAtMGZmMS1jZTAwLTAwMDAwMDAwMDAwMC9zcGxhYkAzYjgwYmU2Yy02NzQxLTQxMzUtOTI5Mi1hZmVkOGRmNTk2YWYiLCJuYmYiOiIxNjczNDEwMzM0IiwiZXhwIjoiMTY5MzQxMDMzNCIsIm5hbWVpZCI6ImMjLnd8QWRtaW5pc3RyYXRvciIsICAgImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vc2hhcmVwb2ludC8yMDA5LzA4L2NsYWltcy91c2VybG9nb25uYW1lIjoiQWRtaW5pc3RyYXRvciIsICAgImFwcGlkYWNyIjoiMCIsICJpc3VzZXIiOiIwIiwgImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vb2ZmaWNlLzIwMTIvMDEvbmFtZWlkaXNzdWVyIjoiQWNjZXNzVG9rZW4iLCAgInZlciI6Imhhc2hlZHByb29mdG9rZW4iLCJlbmRwb2ludHVybCI6ICJGVkl3QldUdXVXZnN6TzdXWVJaWWlvek1lOE9hU2FXTy93eURSM1c2ZTk0PSIsIm5hbWUiOiJmI3h3fEFkbWluaXN0cmF0b3IiLCJpZGVudGl0eXByb3ZpZGVyIjoid2luZE93czphYWFhYSIsInVzZXJpZCI6ImFzYWFkYXNkIn0.YWFh
Với nội dung:
- Header:
{"alg": "none"}
- Payload:
{"iss":"00000003-0000-0ff1-ce00-000000000000","aud": "00000003-0000-0ff1-ce00-000000000000/splab@3b80be6c-6741-4135-9292-afed8df596af","nbf":"1673410334","exp":"1693410334","nameid":"c#.w|Administrator", "http://schemas.microsoft.com/sharepoint/2009/08/claims/userlogonname":"Administrator", "appidacr":"0", "isuser":"0", "http://schemas.microsoft.com/office/2012/01/nameidissuer":"AccessToken", "ver":"hashedprooftoken","endpointurl": "FVIwBWTuuWfszO7WYRZYiozMe8OaSaWO/wyDR3W6e94=","name":"f#xw|Administrator","identityprovider":"windOws:aaaaa","userid":"asaadasdIn0
Với nameid cần phải là một user tồn tại trên SharePoint Site!
.
.
Tới đây thì đã xong bug Bypass Auth, tuy nhiên lại yêu cầu phải biết được ít nhất một username tồn tại trên SharePoint site.
Nếu Username không tồn tại hoặc không được cấp quyền, SharePoint Site sẽ reject request và không thể access tới một tính năng nào cả.
Lúc đầu thì mình nghĩ vấn đề này rất đơn giản, bởi vì user “Administrator” luôn tồn tại trên mọi máy chủ Windows Server 2022, nhưng thực tế lại hơi phũ phàng …
.
.
Yeah, đúng là user “Administrator” tồn tại trên mọi máy chủ Windows Server 2022, tuy nhiên
Với một server SharePoint được setup chuẩn:
- SharePoint Service sẽ không được chạy với quyền của administrator
- Site Admin cũng không phải là administrator
- Duy nhất chỉ có Farm Admin cần phải thuộc group Built-in administrator của SharePoint Server
Điều đó có nghĩa là setup tại pwn2own, user “Administrator” sẽ không phải là một SharePoint Site Member.
Phần này của exploit đã ngốn của mình mấy ngày trời tìm kiếm, lục lọi các writeup cũ về SharePoint, cho tới khi gặp blog sau:
Entrypoint “/my
” trong hình không hề tồn tại trong lab SharePoint của mình!
Sau khi tìm kiếm một hồi, mình mới phát hiện ra rằng hầu hết các setup của ZDI, các team research hiện tại đều sử dụng tính năng Initial Farm Configuration Wizard để khởi tạo các chức năng cơ bản thay vì setup bằng tay như mình :(.
Khi sử dụng tính năng này, rất nhiều tính năng của SharePoint sẽ được enable, trong đó có User Profile Service, phụ trách việc handle entrypoint /my
.
Entrypoint này grant quyền Read
cho Authenticated User
, nghĩa là tất cả các user đều có thể truy cập được trang này.
Từ đó, ta có thể sử dụng bug bypass auth tại entrypoint My Site
site,
- Impersonate user bất kỳ trên SharePoint Server, kể cả các local user
NT AUTHORITY\LOCAL SERVICE
,NT AUTHORITY\SYSTEM
- Request tới
/my/_vti_bin/listdata.svc/UserInformationList?$filter=IsSiteAdmin eq true
để lấy username của Site Admin
Vulnerability #2: Code Injection in DynamicProxyGenerator.GenerateProxyAssembly()
Như đã nhắc ở phần mở đầu, mặc dù ta có thể mạo danh một người dùng bất kỳ, nhưng chỉ thu hẹp trong phạm vi của SharePoint API.
Mình đã tìm kiếm rất lâu về các bug cũ của SharePoint, nhưng không tìm được bug nào reach được qua API (hoặc ít nhất là mình chưa tìm thấy).
Well, và sau đó mình đã mất khoảng nửa năm 2022 để đọc hiểu SharePoint API và tìm được bug này!
Bug này nằm trong method DynamicProxyGenerator.GenerateProxyAssembly().
Phần mã nguồn có liên quan tới bug:
//Microsoft.SharePoint.BusinessData.SystemSpecific.WebService.DynamicProxyGenerator
public virtual Assembly GenerateProxyAssembly(DiscoveryClientDocumentCollection serviceDescriptionDocuments, string proxyNamespaceName, string assemblyPathAndName, string protocolName, out string sourceCode)
{
//...
CodeNamespace codeNamespace = new CodeNamespace(proxyNamespaceName); // [17]
//...
CodeCompileUnit codeCompileUnit = new CodeCompileUnit();
codeCompileUnit.Namespaces.Add(codeNamespace); // [18]
codeCompileUnit.ReferencedAssemblies.Add("System.dll");
//...
CodeDomProvider codeDomProvider = CodeDomProvider.CreateProvider("CSharp");
StringCollection stringCollection = null;
//...
using (TextWriter textWriter = new StringWriter(new StringBuilder(), CultureInfo.InvariantCulture))
{
CodeGeneratorOptions codeGeneratorOptions = new CodeGeneratorOptions();
codeDomProvider.GenerateCodeFromCompileUnit(codeCompileUnit, textWriter, codeGeneratorOptions);
textWriter.Flush();
sourceCode = textWriter.ToString(); // [19]
}
CompilerResults compilerResults = codeDomProvider.CompileAssemblyFromDom(compilerParameters, new CodeCompileUnit[] { codeCompileUnit }); // [20]
//...
}
Logic chính của method này là để tạo một Assembly
với proxyNameSpace
. Tại [17], một instance của CodeNamespace được khởi tạo với parameter là proxyNamespaceName.
Sau đó instance này sẽ được thêm vào codeCompileUnit.Namespaces tại [18].
Tại [19], codeDomProvider.GenerateCodeFromCompileUnit() sẽ generate source code với codeCompileUnit ở trên, bao gồm cả proxyNamespaceName vừa truyền vào, và lưu mã nguồn vào biến sourceCode.
Sau khi kiểm tra một vòng mình thấy rằng không có một bước xác thực nào được thực hiện với proxyNamespaceName (thực ra là có nhưng may mắn là với input của mình thì ko, hehe). Do đó, bằng cách inject code vào param proxyNamespaceName, ta có thể inject code vào Assembly được generate tại [20].
Ví dụ:
- Nếu
proxyNamespaceName = Foo
, code sau khi được generate:
namespace Foo{}
- Nếu
proxyNamespaceName = “Hacked{} namespace Foo”
, mã nguồn sau khi được generate sẽ là:
namespace Hacked{
//Malicious code
}
namespace Foo{}
Method DynamicProxyGenerator.GenerateProxyAssembly() được invoke bằng reflection tại WebServiceSystemUtility.GenerateProxyAssembly():
[PermissionSet(SecurityAction.Assert, Name = "FullTrust")]
public virtual ProxyGenerationResult GenerateProxyAssembly(ILobSystemStruct lobSystemStruct, INamedPropertyDictionary lobSystemProperties)
{
AppDomain appDomain = AppDomain.CreateDomain(lobSystemStruct.Name, new Evidence(new object[]
{
new Zone(SecurityZone.MyComputer)
}, new object[0]), setupInformation, permissionSet, new StrongName[0]);
object dynamicProxyGenerator = null;
SPSecurity.RunWithElevatedPrivileges(delegate
{
dynamicProxyGenerator = appDomain.CreateInstanceAndUnwrap(this.GetType().Assembly.FullName, "Microsoft.SharePoint.BusinessData.SystemSpecific.WebService.DynamicProxyGenerator", false, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, null, null, null, null); // [21]
});
Uri uri = WebServiceSystemPropertyParser.GetUri(lobSystemProperties, "WsdlFetchUrl");
string webServiceProxyNamespace = WebServiceSystemPropertyParser.GetWebServiceProxyNamespace(lobSystemProperties); // [22]
string webServiceProxyProtocol = WebServiceSystemPropertyParser.GetWebServiceProxyProtocol(lobSystemProperties);
WebProxy webProxy = WebServiceSystemPropertyParser.GetWebProxy(lobSystemProperties);
object[] array = null;
try
{
array = (object[])dynamicProxyGenerator.GetType().GetMethod("GenerateProxyAssemblyInfo", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Invoke(dynamicProxyGenerator, new object[] { uri, webServiceProxyNamespace, webServiceProxyProtocol, webProxy, null, httpAuthenticationMode, text, text2 }); // [23]
}
//...
Tại [22], proxyNamespaceName
được lấy ra từ WebServiceSystemPropertyParser.GetWebServiceProxyNamespace(), method này sẽ lấy giá trị WebServiceProxyNamespace
từ LobSystem:
internal static string GetWebServiceProxyNamespace(INamedPropertyDictionary lobSystemProperties)
{
//...
string text = lobSystemProperties["WebServiceProxyNamespace"] as string;
if (!string.IsNullOrEmpty(text))
{
return text.Trim();
}
//...
}
Mặc dù bug trông đơn giản như vậy, nhưng để reach được bug từ phía internet thì lại là một câu chuyện khác.
Kể từ khi MS release advisory đến thời điểm viết bài, mình nhận được khá nhiều câu hỏi về cách reach bug, đa số họ đều thực hiện thông qua admin panel, điều hơi bất khả thi trong thực tế!
Để reach được bug này, mình đã phải mất khá nhiều thời gian tìm đọc docs song song với mã nguồn để hiểu về cách hoạt động của SharePoint API, SharePoint CSOM, Client Query.
Và cuối cùng mình tìm ra được entrypoint nằm ở method Microsoft.SharePoint.BusinessData.MetadataModel.ClientOM.Entity.Execute(). Chức năng cơ bản của method này cho phép load Assembly đã được generated, khởi tạo một Type trong Assembly này và gọi tới một method nằm trong đó.
Mã nguồn của Microsoft.SharePoint.BusinessData.MetadataModel.ClientOM.Entity.Execute() như sau:
...
[ClientCallableMethod] // [24]
...
internal MethodExecutionResult Execute([ClientCallableConstraint(FixedId = "1", Type = ClientCallableConstraintType.NotNull)] [ClientCallableConstraint(FixedId = "2", Type = ClientCallableConstraintType.NotEmpty)] string methodInstanceName, [ClientCallableConstraint(FixedId = "3", Type = ClientCallableConstraintType.NotNull)] LobSystemInstance lobSystemInstance, object[] inputParams) // [25]
{
if (((ILobSystemInstance)lobSystemInstance).GetLobSystem().SystemType == SystemType.DotNetAssembly) // [26]
{
throw new InvalidOperationException("ClientCall execute for DotNetAssembly lobSystem is not allowed.");
}
//...
this.m_entity.Execute(methodInstance, lobSystemInstance, ref array); // [27]
}
Tại [24], do method này có attribute [ClientCallableMethod] nên nó hoàn toàn có thể được access thông qua SharePoint API. Có một đoạn check ở [26] để đảm bảo rằng SystemType của LobSystem không phải là SystemType.DotNetAssembly. Vì như vậy có thể dẫn tới việc thực thi lệnh trực tiếp.
Tuy nhiên, khi nhìn vào param của method Entity.Execute() tại [25], bạn có thể sẽ thắc mắc là làm sao có thể truyền được một instance của LobSystemInstance thông qua SharePoint API?
Việc này có thể làm được thông qua ObjectIdentity của SharePoint Client Query. Mình có thể lấy một LobSystemInstance tùy ý thông qua BCSObjectFactory.
Có thể hiểu đơn giản như thế này, dữ liệu ở phía client sẽ được build ở dạng xml, khi gặp các thẻ <Identity id=”1">, một loạt các Object Factory sẽ được gọi để phân tách dữ liệu và khởi tạo lại object mong muốn, các class này thường override method GetObjectById():
Sau khi lấy được object, ta có thể ref tới các object Identity này thông qua id của từng thẻ.
Ví dụ, ta có thể gửi một request tới /_vti_bin/client.svc/ProcessQuery
với body như sau để lấy được ref của một LobSystemInstance:
<Identity Id="17" Name="<random guid>|4da630b6-36c5-4f55-8e01-5cd40e96104d:lsifile:AAA,AAA" />
Với:
- 4da630b6–36c5–4f55–8e01–5cd40e96104d để sử dụng BCSObjectFactory
- lsifile sẽ trả về LobSystem từ file BDCMetaCatalog
BDCMetaCatalog ở đây là Business Data Connectivity Metadata (BDCM) catalog, mình cũng ko rõ nó dùng để làm gì và sử dụng như thế nào, nhưng biết đại khái là LobSystem, Entity, LobSystemInstance, Method, MethodInstance đều được lưu trong này. Dữ liệu này có thể được lưu ở Database hoặc tại file /BusinessDataMetadataCatalog/BDCMetadata.bdcm của mọi SharePoint Site.
Sau khi tìm hiểu một hồi thì mình phát hiện ra là file này có thể được ghi thoải mái từ internet với quyền của Site Owner (điều mà hiện tại mình đang có thừa ( ͡° ͜ʖ ͡°), không có điều kiện gì quá đặc biệt để có thể ghi được, nó hoàn toàn được coi là một file bình thường trên SharePoint Site.
Điều này có nghĩa là, mình đã có thể control được LobSystem, cũng như các property khác tùy ý, cụ thể đó là property WebServiceProxyNamespace => inject code tùy ý!
Quay trở lại với [27], this.m_entity
có thể là instance của Microsoft.SharePoint.BusinessData.MetadataModel.Dynamic.DataClass hoặc Microsoft.SharePoint.BusinessData.MetadataModel.Static.DataClass. Dù vậy, cả hai đều gọi tới Microsoft.SharePoint.BusinessData.Runtime.DataClassRuntime.Execute()
DataClassRuntime.Execute() sẽ gọi tới DataClassRuntime.ExecuteInternal() -> ExecuteInternalWithAuthNFailureRetry() -> WebServiceSystemUtility.ExecuteStatic():
public override void ExecuteStatic(IMethodInstance methodInstance, ILobSystemInstance lobSystemInstance, object[] args, IExecutionContext context)
{
//...
if (!this.initialized)
{
this.Initialize(lobSystemInstance); // [28]
}
object obj = lobSystemInstance.CurrentConnection;
bool flag = obj != null;
if (!flag)
{
try
{
obj = this.connectionManager.GetConnection(); // [29]
//...
}
//...
}
//...
}
Tại [28], WebServiceSystemUtility.Initialize() sẽ được gọi:
protected virtual void Initialize(ILobSystemInstance lobSystemInstance)
{
INamedPropertyDictionary properties = lobSystemInstance.GetProperties();
//...
this.connectionManager = ConnectionManagerFactory.Value.GetConnectionManager(lobSystemInstance); // [30]
//...
}
Tại [30], ConnectionManagerFactory.Value.GetConnectionManager(lobSystemInstance) sẽ khởi tạo và trả về ConnectionManager cho LobSystem hiện tại. Với WebServiceSystemUtility, this.connectionManager sẽ là instance của WebServiceConnectionManager.
Mã nguồn của WebServiceConnectionManager:
public override void Initialize(ILobSystemInstance forLobSystemInstance)
{
//...
this.dynamicWebServiceProxyType = this.GetDynamicProxyType(forLobSystemInstance); // [31]
this.loadController = LoadController.GetLoadController(forLobSystemInstance) as LoadController;
}
protected virtual Type GetDynamicProxyType(ILobSystemInstance forLobSystemInstance)
{
Type type = null;
Assembly proxyAssembly = ProxyAssemblyCache.Value.GetProxyAssembly(forLobSystemInstance.GetLobSystem()); // [32]
INamedPropertyDictionary properties = forLobSystemInstance.GetProperties();
//...
}
Tại [31], WebServiceConnectionManager.Initialize() sẽ gọi WebServiceConnectionManager.GetDynamicProxyType() và sau đó là ProxyAssemblyCache.GetProxyAssembly() tại [32], để lấy được Type
trong Assembly
đã bị inject và lưu vào this.dynamicWebServiceProxyType
.
Tại [32], ProxyAssemblyCache.GetProxyAssembly() sẽ gọi ICompositeAssemblyProvider.GetCompositeAssembly() với một LobSystem. Ở đây, compositeAssemblyProvider
là một instance của LobSystem
CompositeAssembly ICompositeAssemblyProvider.GetCompositeAssembly()
{
CompositeAssembly compositeAssembly;
ISystemProxyGenerator systemProxyGenerator = Activator.CreateInstance(this.SystemUtilityType) as ISystemProxyGenerator; // [33]
proxyGenerationResult = systemProxyGenerator.GenerateProxyAssembly(this, base.GetProperties()); // [34]
//...
}
Tại [33], một instance của WebServiceSystemUtility được lưu vào systemProxyGenerator
, do đó tại [34] WebServiceSystemUtility.GenerateProxyAssembly() sẽ được gọi.
Ở đây, bởi vì LobSystem được lấy ra từ file BDCMetadataCatalog nên mình có thể control tất cả các properties của LobSystem và inject code vào Assembly được compile!
WebServiceConnectionManager.GetConnection() sẽ được gọi tại [29], ngay sau WebServiceSystemUtility.Initialize() [28]:
public override object GetConnection()
{
//...
try
{
httpWebClientProtocol = (HttpWebClientProtocol)Activator.CreateInstance(this.dynamicWebServiceProxyType);
}
//...
}
Method này sẽ trực tiếp tạo một instance từ this.dynamicWebServiceProxyType
, từ đó sẽ thực thi đoạn mã đã bị inject tại [18]. Đây cũng chính là điểm cuối, điểm thực thi của bug này!
Sau khi kết hợp 2 chain lại thì mọi người đã biết kết quả như nào rồi đó 😁.
Có rất nhiều thứ hay ho khác mình cũng tìm ra trong quá trình làm bug này, tuy nhiên do thời lượng của bài quá dài nên có thể sẽ gom vào một bài sau này.
Đó là một quá trình khó khăn để sống, làm việc và tiếp tục theo đuổi target này. Thật may mắn vì mình có được sự ủng hộ hết lòng của gia đình và người yêu, xin giành những lời cảm ơn chân thành nhất!
Thanks my Weilin, webteam, and chatgpt for reviewing and enrich this nasty blog post
PoC video: https://www.youtube.com/watch?v=V9TXPuFrWvw
__Jang__