Telegram session
劫持探索
本文主要以telegram desktop源码作为起点,对其用户文件和认证过程进行学习和探索,从而更好的理解session劫持内部的机理。
目录结构
Telegram用户数据一般存放在与telegram.exe同目录下的tdata文件夹内。
一个有效的telegram登录用户的目录结构如下:
.
├── 3241E7577682E411s
├── D877F783D5D3EF8C
│ ├── 5DFE1B025533CC34s
│ ├── 80EB1923C860B053s
│ ├── 83481FA7DFF31E68s
│ ├── 9710392255F6037Es
│ ├── 9DD9F15BEB962075s
│ ├── configs
│ └── maps
├── D877F783D5D3EF8Cs
├── ED5F06835F0E7386s
├── countries
├── dumps
├── emoji
│ ├── ...
├── key_datas
├── prefix
├── settingss
├── shortcuts-custom.json
├── shortcuts-default.json
├── user_data
│ ├── cache
│ │ └── 0
│ │ ├── 00
│ │ │ ├── 1868D6DC9582
│ │ │ └── EFB0CD60440D
│ │ ├── ...
│ │ └── binlog
│ └── media_cache
│ ├── 0
│ │ ├── 00
│ │ │ └── 1A7B91AEEBD8
│ │ ├── 01
│ │ ├── ...
│ │ └── binlog
│ └── version
└── usertag
可以看到,大部分文件可以通过文件名来间接的了解文件内部数据所代表的的含义,但仍有一部分文件名由数字+字母(A~F)构成,比如:D877F783D5D3EF8C
,并且telergram的大部分文件的内容为不可读的二进制数据。因此在这里通过阅读源码的方式来探索文件名的含义,从而进一步了解telegram文件的数据构成和登录session的存储位置。
数据文件
A.文件结构
Telegram/SourceFiles/storage/details/storage_file_utilities.cpp:ReadFile()
函数中可以很清楚的看到telergram读取文件的逻辑。数据文件主要分为四个部分,依次为魔数,版本号,加密数据和签名,其中魔数占据文件的前四个字节,固定为TDF$
(telegram desktop file),版本号占据后四个字节(当前为0x002dd278 = 3003000 = v3.3.0
),接下来是加密数据,最后文件末尾为16个字节的签名。
在每次读取文件时,telegram会通过计算“加密数据 + 版本号 + 魔数”的md5值来验证数据的完整性。若计算的结果与文件末尾的签名不一致,则跳过对当前文件的读取。
Telegram/SourceFiles/storage/details/storage_file_utilities.cpp:ReadFile() {
// ...
// check signature
HashMd5 md5;
md5.feed(bytes.constData(), dataSize);
md5.feed(&dataSize, sizeof(dataSize));
md5.feed(&version, sizeof(version));
md5.feed(magic, TdfMagicLen);
if (memcmp(md5.result(), bytes.constData() + dataSize, 16)) {
DEBUG_LOG(("App Info: bad file '%1', signature did not match"
).arg(name));
continue;
}
// ...
}
telegram对部分文件有独特的命名方法,具体在Telegram/SourceFiles/storage/details/storage_file_utilities.cpp:GenerateKey()
函数中进行定义。
GenerateKey()
生成了一个类型为quint64
的随机数,接着调用ToFilePart()
函数对随机数进行转换。
FileKey GenerateKey(const QString &basePath) {
FileKey result;
QString path;
+ 0x11);
path += basePath;
do {
result = base::RandomValue<FileKey>(); // using FileKey = quint64;
path.resize(basePath.size());
path += ToFilePart(result);
while (!result || KeyAlreadyUsed(path));
return result;
}
ToFilePart()
函数使用& 0x0f
操作来获取val
的最后一个字节,并转换为16进制的字符形式,通过循环16次来得到长度为16,由数字+字母(A~F)构成的字符串。
QString ToFilePart(FileKey val) {
QString result;
result.reserve(0x10);
for (int32 i = 0; i < 0x10; ++i) {
uchar v = (val & 0x0F);
result.push_back((v < 0x0A) ? ('0' + v) : ('A' + (v - 0x0A)));
val >>= 4;
}
return result;
}
B.数据解密
DecryptLocal()
函数是telegram用来进行数据加密的主要函数,使用的是1978年由Campbell提出的AES-IGE(Infinite Garble Extension)
加密模式对数据进行加密,主要步骤如下:
调用prepareAES_oldmtp()函数,使用localKey和messageKey生成aesKey ->
调用aesIgeDecryptRaw()函数,使用aesKey来解密数据 ->
使用SHA1算法校验数据的完整性
python的伪代码如下:
decrypted = decryptLocal(mapEncrypted, localKey)
def decryptLocal(encrypted, key):
encryptedKey = encrypted[:16]
decrypted = aesDecryptLocal(encrypted[16:], key, encryptedKey)
digest = sha1(decrypted)[:16]
if digest != encryptedKey:
raise ValueError('App Info: bad decrypt key, data not decrypted')
def aesDecryptLocal(src, authkey, key128):
aesIV = prepareAES_oldmtp(authkey, key128)
dst = bytearray(len(src))
buffer = ffi.from_buffer(dst)
buffer, len(src), aesKey, aesIV)
这里就不花费篇幅贴源代码了,感兴趣的同学可以关注storage_file_utilities.cpp, mtproto_auth_key.h, mtproto_auth_key.cpp
这三个文件,加密的逻辑都在这里了(最终调用的是openssl的加解密接口)。
最后,telegram对于加密文件的解析(storage_file_utilities.cpp: ReadEncryptedFile()
)主要进行了以下三步操作:
调用ReadFile()函数获取加密数据 ->
调用DecryptLocal()函数,使用localkey解密加密的数据 ->
解析明文数据,进行序列化等操作
缓存文件
telegram的缓存文件存储在tdata/user_data/
目录下,其中体积较小的文件放在cache
目录,体积较大的文件则放在media_cache
目录下。缓存文件使用PlaceFromId()
函数生成随机的文件名,这个函数与GenerateKey()
函数的处理逻辑基本一致,区别在于生成的文件名长度为14,并且前两个字节作为上层的文件名,以此来对缓存文件进行分类。
PlaceFromId()
的代码如下:
// https://github.com/desktop-app/lib_storage/blob/master/storage/cache/storage_cache_database_object.cpp
QString PlaceFromId(PlaceId place) {
auto result = QString();
result.reserve(15);
const auto pushDigit = [&](uint8 digit) {
const auto hex = (digit < 0x0A)
? char('0' + digit)
: char('A' + (digit - 0x0A));
result.push_back(hex);
};
const auto push = [&](uint8 value) {
pushDigit(value & 0x0F);
pushDigit(value >> 4);
};
for (auto i = 0; i != place.size(); ++i) {
push(place[i]);
if (!i) {
result.push_back('/');
}
}
return result;
}
// 示例:
// std::array<uint8_t, 7> place = {1, 2, 3, 4, 5, 6, 7};
// PlaceFromId(place) = "11/223344556677"
A.文件结构
telegram缓存文件的文件结构如图:
缓存文件分为三个部分,依次为魔数,基础头信息(Basic Header
)和加密数据。其中魔数占据文件的头4个字节,固定为TDEF
(Telegram Desktop Encrypted File的缩写),Basic Header
是一个结构体,定义在storage/cache/storage_encrpted_file.cpp
中,占据64 + 16 + 32
个字节。
constexpr auto kSaltSize = size_type(64);
constexpr auto kSha256Size = size_type(32);
struct BasicHeader {
bytes::array<kSaltSize> salt = { { bytes::type() } };
uint32 format : 8; // 位域
uint32 reserved1 : 24;
uint32 reserved2 = 0;
uint64 applicationVersion = 0;
bytes::array<openssl::kSha256Size> checksum = { { bytes::type() } };
};
B.数据解密
telegram 使用AES-CTR128
模式对缓存文件进行加密,localKey
是加密所使用的的密钥。这里并没有使用额外的checksum来校验校验后序加密数据的完整性,Basic Header
中的checksum仅仅用来校验Basic Header
结构体内成员数据的完整性。
CTR摸式是一种通过将逐次累加的计数器进行加密来生成密钥流的流密码。CTR模式中,每个分组对应一个逐次累加的计数器,并通过对计数器进行加密来生成密钥流。也就是说,最终的密文分组是通过将计数器加密得到的比特序列,与明文分组进行XOR而得到的。 CTR模式中可以以任意顺序对分组进行加密和解密,因此在加密和解密时需要用到的“计数器”的值可以由nonce和分组序号直接计算出来,因此CTR模式能够以任意顺序处理分组,就意味着能够实现并行计算。 |
解密缓存文件的python代码如下:
with open(path, 'rb') as f:
if f.read(4) != b'TDEF':
raise ValueError('wrong file type')
salt = f.read(64)
encrypted = f.read(16 + 32)
real_key = sha256(key[:len(key) // 2] + salt[:32])
iv = sha256(key[len(key) // 2:] + salt[32:])[:16]
d = CtrState(real_key, iv)
data = d.encrypt(encrypted)
checksum = data[16:]
if sha256(key + salt + data[:16]) != checksum:
raise ValueError('wrong key')
return d.encrypt(f.read())
解密后可以很清楚的看到,图片,表情,gif等文件主要都缓存在cache
目录中,音频和视频主要缓存在media_cache
中。
telegram使用了webp的技术来存储一部分的头像和表情,这么做能够使得体积大幅减少,图片质量也得到保障。
除了webp之外,telegram还有自己的tgs
文件 (Telegram Animated stickers),主要使用的是Lottie的技术来存储更高质量的动画表情,然后用gzip压缩,进一步减小动画表情缓存文件的大小。这个github项目可以很方便地将tgs
文件转换为gif
文件。
# key_datas
在对文件的结构有初步的了解后,我们的第一个目标来到key_datas
文件,这个文件虽然仅存储了较少的用户数据,但它是所有文件中最关键的文件,因为它保存了解密其他文件的主密钥localKey
。
经过一番寻找,发现在Telegram/SourceFiles/storage/storage_domain.cpp:Domain::startModern()
函数中对key_datas
文件进行了读取,解密以及数据解析等操作,主要步骤如下:
调用ReadFile()函数获取加密数据(keyData) ->
解析keyData,读取salt, keyEncrypted, infoEncrypted ->
调用createLocalKey()函数生成passcodeKey ->
调用DecryptLocal()函数和密钥导出函数,使用导出密钥解密keyEncrypted,得到localKey ->
调用DecryptLocal()函数,使用localkey解密infoEncrypted,得到info->
解析用户数据,初始化账户设置
这里需要关注三个点:
createLocalKey()
首先以sha512(salt + passcode + salt)
的形式生成hash值。接下来再调用PKCS5_PBKDF2_HMAC_sha512
密钥导出函数,将hash
和salt
作为输入参数,进行重复计算后得到最终的导出密钥passcodeKey
。
MTP::AuthKeyPtr CreateLocalKey(const QByteArray &passcode, const QByteArray &salt) {
const auto s = bytes::make_span(salt);
const auto hash = openssl::Sha512(s, bytes::make_span(passcode), s);
const auto iterationsCount = passcode.isEmpty()
? 1 // Don't slow down for no password.
: kStrongIterationsCount;
auto key = MTP::AuthKey::Data{ { gsl::byte{} } };
PKCS5_PBKDF2_HMAC(
reinterpret_cast<const char*>(hash.data()),
hash.size(),
reinterpret_cast<const unsigned char*>(s.data()),
s.size(),
iterationsCount,
EVP_sha512(),
key.size(),
reinterpret_cast<unsigned char*>(key.data()));
return std::make_shared<MTP::AuthKey>(key);
}
passcodeKey
会调用DecryptLocal()
函数来解密keyEncrypted
,得到localKey
。
_passcodeKey = CreateLocalKey(passcode, salt);
EncryptedDescriptor keyInnerData, info;
if (!DecryptLocal(keyInnerData, keyEncrypted, _passcodeKey)) {
LOG(("App Info: could not decrypt pass-protected key from info file, "
"maybe bad password..."));
return StartModernResult::IncorrectPasscode;
}
auto key = Serialize::read<MTP::AuthKey::Data>(keyInnerData.stream);
_localKey = std::make_shared<MTP::AuthKey>(key);
解密得到的localkey
不只是用来解密infoEncrypted
,同时它还是其他加密文件的解密密钥(可以理解为它是主密钥)。程序在这里使用了std::move()
操作,将localKey
的资源移动给_localKey
,_localKey
则是后序解密文件所使用的对称密钥。
Domain::StartModernResult Domain::startModern(const QByteArray &passcode) {
auto key = Serialize::read<MTP::AuthKey::Data>(keyInnerData.stream);
// ...
_localKey = std::make_shared<MTP::AuthKey>(key);
// ...
auto config = account->prepareToStart(_localKey);
}
std::unique_ptr<MTP::Config> Account::prepareToStart(std::shared_ptr<MTP::AuthKey> localKey) {
return _local->start(std::move(localKey));
}
std::unique_ptr<MTP::Config> Account::start(MTP::AuthKeyPtr localKey) {
Expects(localKey != nullptr);
_localKey = std::move(localKey);
readMapWith(_localKey);
clearLegacyFiles();
return readMtpConfig();
}
下图为解密后的key_datas
文件数据:
在拥有了加解密文件的密钥_localKey
后,剩下的加密文件就可以轻而易举的解开了。
# settings
settingss
文件主要存储了用户页面相关的配置,包括背景图片,颜色,语言包等配置信息。
# D877F783D5D3EF8Cs
D877F783D5D3EF8Cs
文件主要存储了用户的userId,以及与telegram云端进行数据通信时所使用到的加密密钥。
# D877F783D5D3EF8C/maps
maps
文件中lskSelfSerialized
字段存储了用户的基本信息,包括用户id,头像,姓名,注册电话,上次在线时间等信息。而其他字段主要存储的是一些配置或者资源文件的文件名,并且与文章开头列举的D877F783D5D3EF8C
文件夹下的文件一一对应。
# D877F783D5D3EF8C/configs
configs
文件主要存储了用户聊天,与telegram云端进行通信时的一些基础配置,包括telegram云端的ip和端口,撤回消息的时长限制等配置信息。
# 缓存文件
user_data
目录下的缓存文件(图片,语音,视频)大部分都可以通过解密和解析数据得到原始的文件,在这里就不赘述了。
telegram官网很详细地介绍了Cloud Chat
和Secret Chat
的两种通信方式所使用的加密协议(mproto
),密钥交换,身份认证等过程,有兴趣的同学可以配合源码来阅读文档,在这里也不赘述了。
这里讨论两个比较有意思的点:
-
telegram所使用的
mproto
通信协议是telegram开发者自己实现的一套完整的安全通信协议,不依靠于其他公认的安全通信协议。 -
telegram选择的是
AES-IGE
模式对文件和消息进行加密,这是一个十分古老的aes加密模式,而且相比于现在常用的加密模式,他并没有什么优势,因此基本上没有人使用AES-IGE
的加密模式。这个加密模式在认证上还缺失一定的安全性,比如无法抵御
blockwise-adaptive CPA
攻击。telegram还专门写了一大章节的文字来说明在mproto
协议下使用这个模式是安全的(没有选择更换加密模式的原因,可能是为了前后兼容性吧)。在搜索资料的过程中,发现许多网友都质疑telegram使用这种加密模式,并在论坛上给官方提了很多“意见”。
传送门 => https://news.ycombinator.com/item?id=6915741,https://github.com/telegramdesktop/tdesktop/issues
telegram 不像 whatsapp,默认是支持多端登录的,这也是telegram能通过迁移tdata的方法来保持session的原因。这种特性对于攻击者来说,就变成了劫持session的最完美的前置条件。
现在流传在网上的session劫持方法都是通过复制整个tdata文件夹进行session的劫持,但对于一个使用了较长时间的telegram来说,tdata的体积会变得十分庞大(MB,甚至GB的量级),这在实战的过程中会有很大的局限性。
现在我们已经有解读大部分tdata文件的能力,因此可以选取最关键(保存session)的文件,减小拖取文件的体积,从而更方便于session的劫持。
通过前面对文件的解密,以及一系列的尝试后,得出能够成功进行session劫持的关键文件有:
-
tdata/key_datas
-
tdata/D877F783D5D3EF8Cs
-
tdata/D877F783D5D3EF8C/map
原因如下:
key_datas
保存了解密文件的密钥localKey
,D877F783D5D3EF8Cs
保存了与云端通信的主密钥,D877F783D5D3EF8C/map
保存了用户的基本信息。
比较有意思的一点是,D877F783D5D3EF8Cs
这个文件是ToFilePart(substr(md5("data"), 0, 16))
的结果,这也从侧面证明了这个文件存储了关键数据。
In[163]: import hashlib
In[164]: md5 = hashlib.md5('data'.encode("utf-8")).digest()
In[165]: md5
Out[165]: b'x8dwx7f8]=xfexc8x81] xf7I`&xdc'
In[166]: int.from_bytes(md5, 'little')
Out[166]: 292629419324765554216674928803425777549
In[167]: ToFilePart(292629419324765554216674928803425777549)
Out[167]: 'D877F783D5D3EF8C'
Out[168]: 'data : D877F783D5D3EF8C -> 8d777f385d3dfec8 (substr (md5_hex ("data"), 0, 16))'
Out[169]: 'data#2 : A7FDF864FBC10B77 -> 7adf8f46bf1cb077 (substr (md5_hex ("data#2"), 0, 16))'
Out[170]: 'data#3 : F8806DD0C461824F -> 8f08d60d4c1628f4 (substr (md5_hex ("data#3"), 0, 16))'
Out[171]: 'data#4 : C2B05980D9127787 -> 2c0b95089d217778 (substr (md5_hex ("data#4"), 0, 16))'
Out[172]: 'data#5 : 0CA814316818D8F6 -> c08a411386818d6f (substr (md5_hex ("data#5"), 0, 16))'
注意事项
#1
有的情况下,一个telegram客户端可能登录着多个用户(不超过3个),所有用户的session文件依旧保存在tdata
下。telegram客户端会根据登录的次序进行编号,分别为data
,data#2
,data#3
,在session劫持的时候按照文件名进行迁移即可。
#2
在进行session劫持的过程中,如果一直都无法恢复session,并且能够确认session文件没有过期,那么有可能是本地的Telegram.exe版本与目标机器的Telegram.exe不匹配造成的。
telegram在key_datas
,maps
,D877F783D5D3EF8Cs
等文件中都将当前的版本号存储在文件的第4至第8个字节,并且在调用ReadFlie()
函数时,telegram客户端会判断文件中的版本号是否大于客户端版本。若大于,则会直接停止读取文件,终止session的恢复。
// read app version
qint32 version;
if (f.read((char*)&version, sizeof(version)) != sizeof(version)) {
DEBUG_LOG(("App Info: failed to read version from '%1'").arg(name));
continue;
}
if (version > AppVersion) {
DEBUG_LOG(("App Info: version too big %1 for '%2', my version %3").arg(version).arg(name).arg(AppVersion));
continue;
}
因此,比较取巧的一个办法是,使用当前最新版本的客户端,这样就不会遇到版本不匹配的问题了。
在进行session劫持的过程中,有概率会遇到含有passcode的情况。在这种情况下,迁移过来的telegram会呈现下面这种状态,导致攻击者无法查看telegram的信息。
打开DebugLogs/log_xx_xx.txt
就可以看到因未提供passcode,而无法解密所产生的错误日志信息:
根据日志输出的信息,可以定位到storage_domain.cpp:startModern()
(读取key_data
文件)函数,更细一点来说,是在storage_file_utilities.cpp:DecryptLocal()
函数中校验checksum的部分。
跟一下代码前后的处理逻辑,用python写出对应的处理流程:
def decryptLocal(encrypted, key) -> bytes:
encryptedKey = encrypted[:16]
decrypted = aesDecryptLocal(encrypted[16:], key, encryptedKey)
digest = sha1(decrypted)[:16]
if digest != encryptedKey:
raise ValueError('App Info: bad decrypt key, data not decrypted - maybe has passcode)
dataLen = int.from_bytes(decrypted[:4], 'little')
return decrypted[4:dataLen]
def createLocalKey(passcode, salt) -> bytes:
iterCount = 100_000 if passcode else 1
hash = hashlib.sha512(salt + passcode + salt).digest()
key = hashlib.pbkdf2_hmac("sha512", hash, salt, iterCount, 256)
return key
qstream = readFile('key_data')
salt = qstream.readBytes()
keyEncrypted = qstream.readBytes()
infoEncrypted = qstream.readBytes()
_passcodeKey = createLocalKey(passcode, salt)
_localKey = decryptLocal(keyEncrypted, _passcodeKey)
可以看到,本质上telegram是通过比对解密后数据的checksum和原始明文的checksum来确认passcode是否正确,若两者的checksum相等,则说明解密所得到的localKey
是正确的。
因此,将上述解密localKey
的处理流程逆一下,就可以得到加密localKey的流程,但因为在有passcode的情况下,密钥导出函数的迭代次数为100000次,这极大的增加了爆破所需的时长。
这里我使用的是john-the-ripper
爆破工具,bleeding-jumbo
分支已经集成了telegram passcode爆破模块,支持旧版本和新版本telegram的passcode爆破(在telegram小于v2.1.14以前的迭代次数为4000,并且使用的是PKCS5_PBKDF2_HMAC_SHA1
的密钥导出函数),但比较可惜的一点的是,新版本的passcode爆破没有GPU版,只能使用CPU进行爆破。
在passcode解密研究的过程中,同样发现两个比较有意思的点:
-
passcode仅仅影响了
key_data
单个文件,其他文件的内容并不会发生变化。并且在设置passcode前和设置passcode后,key_data
文件内的localKey
始终保持不变。 -
passcode(local passcode)将他的字面意思贯彻到了极致,也就是说passcode仅针对于当前的session,而不会上传到云端,更不会影响到其他session的使用。而在session劫持的过程中,因为攻击者是通过复制目标的session文件来达到劫持的目的,这样意味着攻击者和目标使用的是同一个session,而此时session文件已经被passcode进行了二次加密,所以迁移过来的session也需要输入同样的passcode才能进入程序的主页面。
A.目录结构
从目录结构可以看出,pdata
的目录结构类似于较低版本的telegram目录结构。
在较低版本的telegram中,map*
文件不仅仅存储了登录用户的信息和各种配置信息,同时还存储了解密文件的主密钥localKey
。在后续高版本的telegram中,才将这两部分数据分别存储在maps
文件和key_datas
文件。
B.session 劫持
尝试将telegram session劫持的思路运用在potato上,会发现无法成功。将日志与比对成功的session劫持的日志进行比对,发现在恢复session的过程中,调用了类似于getMacAddress
函数来获取本机的MAC地址。
因为potato是闭源软件,所以只能通过比对telegram的源码和potato产生的log日志来猜测potato二开的代码逻辑。但是在仔细比对了多个telegram版本的源代码后,并经过了漫长的断网和联网测试,发现telegram客户端中并没有获取本机MAC地址的操作,因此可以有如下的推测:
-
potato在session恢复中加入了MAC地址的校验,若本机MAC地址和session文件中存储的MAC地址不一致则无法恢复session。
-
目标的MAC地址存储在session文件内。
先来验证第一点,这里我使用的是虚拟机作为劫持端,因为虚拟机可以很轻松的修改MAC地址,并且不会出现断网的情况。迁移方案与低版本的telegram迁移方案一致,复制D877F783D5D3EF8C*
和D877F783D5D3EF8C/map*
两个文件。从下图可以看到,迁移成功!
接下来验证第二点,这里可以借用解密telegram文件的方法来解密potato文件,但因为potato使用的是低版本的telegram进行二次开发,所以需要更改部分的解密时所使用的加密算法(比如密钥导出函数)和代码逻辑,并且potato增加了一些telegram中没有的字段和数据结构,这给解析数据造成了很大的困扰。
最后根据一番猜测和努力,能成功解密一部分的数据,其中MAC地址就存储在D877F783D5D3EF8C*
文件中。
长沙零鉴科技有限公司是一家以前沿安全技术为尖刀,高水平安全人才为支柱,在信息安全日益严峻的形势下专业从事网络信息安全技术的自主创新型企业,零鉴科技致力于打击网络犯罪领域的技术研究与产品研发。
截至目前,零鉴科技已为不同省市的多家执法机关提供高效精确的反网络犯罪情报分析服务和优质的安全解决方案。
也欢迎志同道合的小伙伴们与我们共同进步~
简历投递邮箱:[email protected]
原文始发于微信公众号(零鉴科技):Telegram session劫持探索