개요
한국형 랜섬웨어 Venus Locker 가 이슈화 됐습니다. 정교한 한글번역, 정말 읽고 싶게 만드는 파일명 선정, 그리고 pdb의 한글경로 및 그럴싸한 오타까지. 공격자 국적을 유추할 수 있는 단서가 많이 나오는 랜섬웨어 샘플입니다.
복호화 가능여부
이 랜섬웨어는 C&C 서버 접근 성공 여부에 따라 사용하는 암호화 키가 다릅니다. 성공 시에는 PC 정보를 기반으로 키를 생성 및 서버로 전송하기에 복호화가 불가능합니다. 하지만 서버 접속에 실패한다면 하드코딩된 암호화 키를 이용합니다. 따라서 샘플만 입수된다면 복호화 가능성이 열려있습니다.
동작방식
유포된지 며칠 지나지 않은 랜섬웨어지만, 벌써부터 변종이 관찰됩니다. 유포 초기에는 동봉되는 여러 파일 중 .doc 파일이 실행파일이었지만, 최근 관찰된 샘플은 실제 워드 문서를 사용합니다. 그리고 워드에 포함된 매크로에서 랜섬웨어 본체를 실행합니다.
두 경우 오프라인 암호화키는 zyQCCu4Ml*4T=v!YP4oe9S5hbcoTGb8A
와 BGORMkj&v=u1X0O2hOybNdRvZb9SGGnm
로 서로 달랐습니다. 샘플간 하드코딩된 암호화키가 다르기 때문에 설령 C&C 서버가 죽은 상태에서 파일이 암호화 되었더라도, 하드코딩된 키를 알아내는 과정이 필요합니다.
키 획득 과정
하드코딩된 키를 알아내기 위해선 두 번의 언패킹을 수행해야 합니다. 초기 버전에서는 MFC 로 패킹했고, 최근 버전에서는 dotNet 으로 패킹했다는 차이가 있으나, AntiVM 및 최종 드랍 랜섬웨어 코드상에선 큰 차이가 없습니다.
Venus Unlocker
분석을 진행함에 있어, 하드코딩된 암호화키를 이용한 복호화 프로그램을 만들어봤습니다. 난생 처음 해보는 dotNet 코딩이라 코드가 엉망이지만 일단 동작은 확인했습니다.
복호화 전
복호화 후
아래 소스코드는 모든 Venus Locker 로 암호화된 파일을 복호화하지 못합니다. 매우 특정한 상황에서만 가능한 것으로 연구목적으로 작성했습니다.
복호화 조건
- 감염시점에 C&C 서버 접속불가
- 랜섬웨어 샘플 확보
- 랜섬웨어 샘플 상세분석
샘플에 대한 자세한 분석은 자체 분석보고서에서 확인하실 수 있습니다.
소스코드
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Security.Cryptography;
using System.Security.Cryptography;
using System.IO;
// http://onlyican.tistory.com/207 와 Venus Locker 참고
namespace VenusDecryptor
{
class Program
{
static void Main(string[] args)
{
string OFFLINE_KEY = "zyQCCu4Ml*4T=v!YP4oe9S5hbcoTGb8A";
DirectoryInfo directoryInfo = new DirectoryInfo(@"c:\test");
FileInfo[] files = directoryInfo.GetFiles();
for (int i = 0; i < files.Length; i++)
{
// base64 인코딩된 파일명 복원
string encFilePath = files[i].FullName;
string encFileName = Path.GetFileNameWithoutExtension(encFilePath);
byte[] newBytes = Convert.FromBase64String(encFileName);
string restoredFileName = Encoding.Default.GetString(newBytes);
// VenusLocker 조건문에 따른 복호화방식 구분
byte[] result = null;
if (files[i].Extension == ".VenusLfS" || files[i].Extension == ".VenusLf")
{
byte[] stream = System.IO.File.ReadAllBytes(files[i].FullName);
result = AESDecrypt256(stream, OFFLINE_KEY, true);
// 파일 끝까지 복구
System.IO.File.WriteAllBytes(@"C:\test\out\" + restoredFileName, result);
}
else
{
System.IO.File.Copy(files[i].FullName, files[i].FullName+"tmp", true);
FileStream fileStream = new FileStream(files[i].FullName+"tmp", FileMode.Open, FileAccess.ReadWrite);
byte[] stream = new byte[1024];
fileStream.Read(stream, 0, 1024);
result = AESDecrypt256(stream, OFFLINE_KEY, false);
// 파일 1024만 복구 (나머진 암호화 안돼있음)
fileStream.Seek(0L, SeekOrigin.Begin);
fileStream.Write(result, 0, 1024);
fileStream.Close();
System.IO.File.Move(files[i].FullName + "tmp", @"C:\test\out\" + restoredFileName);
}
}
}
//AES_256 복호화
static byte[] AESDecrypt256(byte[] bytesToBeDecrypted, string Key, bool isPadding)
{
byte[] result = null;
byte[] passwordBytes = Encoding.UTF8.GetBytes(Key);
passwordBytes = SHA256.Create().ComputeHash(passwordBytes);
byte[] salt = new byte[]
{
1,
2,
3,
4,
5,
6,
7,
8
};
using (MemoryStream memoryStream = new MemoryStream())
{
using (RijndaelManaged rijndaelManaged = new RijndaelManaged())
{
rijndaelManaged.KeySize = 256;
rijndaelManaged.BlockSize = 128;
Rfc2898DeriveBytes rfc2898DeriveBytes = new Rfc2898DeriveBytes(passwordBytes, salt, 1000);
rijndaelManaged.Key = rfc2898DeriveBytes.GetBytes(rijndaelManaged.KeySize / 8);
rijndaelManaged.IV = rfc2898DeriveBytes.GetBytes(rijndaelManaged.BlockSize / 8);
rijndaelManaged.Mode = CipherMode.CBC;
if (!isPadding)
{
rijndaelManaged.Padding = PaddingMode.None;
}
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, rijndaelManaged.CreateDecryptor(), CryptoStreamMode.Write))
{
cryptoStream.Write(bytesToBeDecrypted, 0, bytesToBeDecrypted.Length);
cryptoStream.Close();
}
result = memoryStream.ToArray();
}
}
return result;
}
}
}
혹시 궁금한게 있는데 몇가지 질문해도 괜찮을까요??
Rfc2898DeriveBytes함수가 벡터값이랑 key값을 난수로 생성해주는걸로 알고있는데
난수값으로 생성해야하는 이유가 뭔지 알수있을까요??
그리고 키값이랑 벡터값이랑 메모리스트림으로 크립토 스트림을 생성하고
write하는게 새로운 파일을 만들고 복호화 시킨후 원래있던 파일에 덮어씌우는 방식으로 돌아가는게 맞나요??
메모리 스트림에서 Toarray()를 하는건 배열형식으로 decryptedbytes에 담는건가요??
그러면 리턴값으로 반환할때 무엇이 반환되는건가요??
Rfc2898DeriveBytes 의 경우, 대게 암호화 키는 암호화 알고리즘에 따라 키의 길이가 정해져있습니다. 제법 긴 그 키를 의미있는 암호(?)로 직접 입력하기 보단 의미있는 암호의 해쉬값을 키로 사용합니다. 이 과정에서 솔트값도 추가되기도 하구요. 이는 .net 뿐 아닌 모든 암호화 알고리즘의 공통된 사항입니다. 이유라면… 키 길이가 정말 길어서…;? 이 부분에 대해선 저도 정확한 답변을 드리기 어렵군요; 아마 난수갑 생성보단 해쉬화 가 더 정확한 표현일겁니다.
기존 암호화 된 파일을 읽어서 복호화시키고, 새로운 파일에 씁니다. (기존 파일을 덮어쓰지 않습니다)제가 .net 이 처음이라 어설픈 실력에 가독성이 많이 떨어지는군요;;
마지막 return 전에 사용된
result = memoryStream.ToArray();
메쏘드는 복호화된 스트림을 반환하는데 사용했습니다. memoryStream MSDN 을 직접 확인하시는게 제 설명보다 나을것 같습니다.