从VC++ 6.0随机密码生成器看rand()缺陷与Crypto API改造
2026/9/16 14:12:46 网站建设 项目流程

简介:面向VC++初学者及需要实现安全随机密码功能的开发者,这份工程示例完整演示了在Visual C++环境下生成随机密码的实现思路,可有效解决如何组合大小写字母、数字与特殊字符的常见需求。压缩包共含16个文件,以C++源码(.cpp/.h)、对话框资源文件(.rc/.rc2)、图标文件与Visual Studio旧版工程文件(.dsw/.dsp)为主,整包仅15KB,结构紧凑便于阅读。已有214人学习浏览。源码中通过RdPass类及对话框实现,清晰展示了随机数生成、字符集索引、用户界面交互等核心逻辑,并配有预编译头文件与工程配置,既适合初学者对照调试、理解MFC程序框架,也可作为起点进一步扩展为支持密码强度检查、自定义密码策略的实用安全工具。

1. 一个 VC++ 6.0 时代的随机密码生成器仍在被反复打开

文件名RdPass.dsw对今天的开发者来说已经相当陌生,但它确实是在 Visual C++ 6.0 环境下可直接打开的 MFC 对话框工程。整个vc生成随机密码.zip压缩包内源码文件不到十个,核心逻辑集中在RdPassDlg.cpp中:用户输入密码长度、勾选字符集,点击生成按钮后由rand()从字符池抽取字符,拼成随机密码回显到编辑框,并提供一键复制。这个工具本身不复杂,却把 MFC 控件绑定、预编译头机制、.dsp/.dsw老工程结构和伪随机数精度四个知识点串在了一起,既适合 MFC 初学者当练手项目,也适合需要做老代码迁移的工程师作为参照样本。

2. rand() 伪随机序列与 RdPass 随机种子初始化细节

2.1 RAND_MAX 只有 32767:取模偏差从哪来

在 Visual C++ 6.0 的 CRT 实现中,rand()底层是线性同余生成器,返回 0 到RAND_MAX的整数,而这里的RAND_MAX是 32767,也就是 15 位随机数。RdPass 生成密码时普遍采用rand() % nPoolSize的方式取字符索引,这有两个层面的隐患。

第一个隐患是取模的 Modulo Bias。假定字符池有 67 个字符,32768 除以 67 等于 489 余 5,那么余数 0 到 4 这五个值出现的概率是 490/32768,其余 62 个余数的概率是 489/32768。虽然单个字符的偏差绝对值很小,但密码生成会连续调用几十次,偏差会被累积放大,安全审计时这是明确的扣分项。

字符池大小 N32768 / N 的商余数 r受影响的余数个数
625283232 个余数多 1 次机会
6748955 个余数多 1 次机会
814044444 个余数多 1 次机会
943485656 个余数多 1 次机会

第二个隐患是低位比特周期问题。线性同余算法的最低几位随机性很弱,rand() % 2的结果会以固定的短周期反复出现。直接取模等于把随机性最差的那几个比特暴露给调用者,这在密码场景下不可接受。正确做法是丢弃低比特,用高位参与映射:

// 生成 0 到 nPoolSize-1 的近似均匀索引 int nRand = rand() >> 4; // 丢弃低 4 位,保留高 11 位 int nIndex = (int)((double)nRand / 2048.0 * nPoolSize); // 浮点缩放替代取模 if (nIndex >= nPoolSize) nIndex = nPoolSize - 1; // 防御浮点边界溢出

rand() >> 4把随机范围变成 0 到 2047,double除法得到 0 到 1 之间的小数,再乘以池大小映射到目标区间。相比直接%,这种映射在不同池大小下都保持近似均匀。需要强调的是,这只能修复分布偏差,不能改变rand()本身周期有限的事实。

2.2 种子初始化位置决定了密码是否重复

RdPass 工程里种子的调用点通常在窗口初始化阶段:

BOOL CRdPassDlg::OnInitDialog() { CDialog::OnInitDialog(); srand((unsigned)time(NULL)); // 以系统秒级时间作为种子 return TRUE; }

time(NULL)返回自 1970 年以来的秒数,种子空间只有约 2^31 量级。更严重的问题是,如果srand()被放到生成按钮的点击事件里,用户在 1 秒内连续点击两次,两次生成的随机密码序列会完全一样。老代码里最常见的错误就是“每次生成前重新播种”,这会让rand()退化成一张按时间查表。

提示:srand()整个进程只需要调用一次。凡是把srand()写进按钮响应函数里的实现,都需要把调用点移到OnInitDialog()或全局初始化处。

如果希望扩大种子空间,可以组合多个输入源:

// 组合时间、进程 ID 和栈地址随机性,扩大种子空间 unsigned nSeed = (unsigned)time(NULL); nSeed ^= (GetCurrentProcessId() << 16); nSeed ^= (unsigned)(UINT_PTR)&nSeed; // 栈地址本身带随机性 srand(nSeed);

2.3 字符池构建:从静态字符串到动态拼接

RdPass 工程中通常定义了静态字符串作为字符集,再根据复选框状态决定把哪些字符串拼入临时池:

char GetRandomChar(BOOL bUpper, BOOL bLower, BOOL bDigit, BOOL bSpecial) { char pool[256] = {0}; if (bLower) strcat(pool, "abcdefghijklmnopqrstuvwxyz"); if (bUpper) strcat(pool, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); if (bDigit) strcat(pool, "0123456789"); if (bSpecial) strcat(pool, "!@#$%^&*()-_=+[]{}:;?/"); int nSize = (int)strlen(pool); if (nSize == 0) return '\0'; int nRand = rand() >> 4; int nIndex = (int)((double)nRand / 2048.0 * nSize); if (nIndex >= nSize) nIndex = nSize - 1; return pool[nIndex]; }

四个字符集全部勾选时,池大小为 26+26+10+22=84,char pool[256]有足够冗余。真正要注意的是strcat的缓冲区长度,如果在 VC++ 6.0 下没有给数组固定大小,编译器不会主动报错,但运行时可能覆盖相邻栈数据。更稳妥的做法是用strncat并显式传入剩余容量。字符池顺序也会影响生成结果:如果你希望所有字符等概率出现,字符池中不能有重复字符,拼接时要避免把同一字符集加入两次。

3. RdPassDlg 对话框工程:控件 ID 与 MFC 消息流

3.1 resource.h 中控件 ID 的分配约定

MFC 对话框工程的界面布局写在RdPass.rc文本资源文件里,所有控件 ID 常量集中在resource.h,编译时RdPassDlg.cpp通过#include "resource.h"引用这些 ID。RdPass 典型的 ID 分配如下:

控件ID 宏典型值说明
密码长度输入框IDC_EDIT_LENGTH1000接收 4-64 的整数
大写字母复选框IDC_CHECK_UPPER1001勾选后启用大写字符集
小写字母复选框IDC_CHECK_LOWER1002勾选后启用小写字符集
数字复选框IDC_CHECK_DIGIT1003勾选后启用数字字符集
特殊字符复选框IDC_CHECK_SPECIAL1004勾选后启用特殊字符集
生成按钮IDC_BTN_GENERATE1005触发密码生成
结果显示编辑框IDC_EDIT_RESULT1006显示生成的密码,通常设只读

resource.h约定俗成用IDC_前缀表示控件,IDD_表示对话框模板,IDR_表示菜单或图标资源。如果你在类向导里删除了一个控件却没有同步清理.rc文件中对应的DEFPUSHBUTTONPUSHBUTTON行,编译可能不报错,但对话框运行时布局会出现错位,排查成本很高。

3.2 DoDataExchange 中的 DDX/DDV 绑定

RdPassDlg.cppDoDataExchange是 MFC 对话框的数据交换中心:

void CRdPassDlg::DoDataExchange(CDataExchange* pDX) { CDialog::DoDataExchange(pDX); // {{AFX_DATA_MAP(CRdPassDlg) DDX_Text(pDX, IDC_EDIT_LENGTH, m_nLength); DDV_MinMaxInt(pDX, m_nLength, 4, 64); DDX_Check(pDX, IDC_CHECK_UPPER, m_bUpper); DDX_Check(pDX, IDC_CHECK_LOWER, m_bLower); DDX_Check(pDX, IDC_CHECK_DIGIT, m_bDigit); DDX_Check(pDX, IDC_CHECK_SPECIAL, m_bSpecial); DDX_Text(pDX, IDC_EDIT_RESULT, m_strPassword); // }}AFX_DATA_MAP }

DDX_Text绑定到int时输入非数字会触发类型转换提示,绑定到CString时接收任意文本。DDV_MinMaxInt是数据校验宏,把长度限制在 4 到 64 之间,超出时焦点不会转移到其他控件,而是停在出错框上并弹出提示。注意// {{AFX_DATA_MAP注释标记是类向导的领地,手工新增DDX_语句时如果破坏了上下两行标记,下次运行类向导会把这些绑定全部清掉。

3.3 生成按钮点击后的完整消息流

生成按钮的响应函数维持着基本流程:

void CRdPassDlg::OnBnClickedBtnGenerate() { if (!UpdateData(TRUE)) // 控件同步到成员变量 return; if (m_nLength < 4 || m_nLength > 64) { AfxMessageBox(_T("长度应在 4 到 64 之间")); return; } m_strPassword.Empty(); for (int i = 0; i < m_nLength; i++) { m_strPassword += GetRandomChar(m_bUpper, m_bLower, m_bDigit, m_bSpecial); } UpdateData(FALSE); // 成员变量写回控件 }

UpdateData(TRUE)调用DoDataExchange把控件内容读入成员变量,UpdateData(FALSE)反向把成员变量刷新到控件。如果忘记先调用UpdateData(TRUE)m_bUpper等复选框状态会停留在上一次的值,看起来像是按钮没有反应。消息映射由RdPassDlg.h中的afx_msg void OnBnClickedBtnGenerate();声明和RdPassDlg.cppBEGIN_MESSAGE_MAP中的ON_BN_CLICKED(IDC_BTN_GENERATE, &CRdPassDlg::OnBnClickedBtnGenerate)共同组成,VC++ 6.0 下这些映射由类向导自动生成,手工修改可能导致RdPass.clw与代码不一致,类向导的控件事件列表会变成空的。

3.4 剪贴板复制与 HGLOBAL 所有权

密码生成后复制到剪贴板是高频操作,这里有一个资源所有权陷阱:

void CRdPassDlg::OnBnClickedBtnCopy() { if (m_strPassword.IsEmpty()) return; if (!OpenClipboard()) return; EmptyClipboard(); int nBytes = (m_strPassword.GetLength() + 1) * sizeof(TCHAR); HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, nBytes); if (hMem != NULL) { LPTSTR pBuf = (LPTSTR)GlobalLock(hMem); _tcscpy(pBuf, (LPCTSTR)m_strPassword); GlobalUnlock(hMem); SetClipboardData(CF_UNICODETEXT, hMem); } CloseClipboard(); }

GlobalAlloc之后不能再手动GlobalFree,剪贴板接管了HGLOBAL的释放责任。字符集方面,如果工程在 VC++ 6.0 下使用多字节字符集,而这里设置CF_UNICODETEXT,粘贴时会按 UTF-16 数据被目标程序按 ANSI 读取,出现乱码。稳妥的做法是改用CF_TEXT,或者干脆把整个工程切换为 Unicode 字符集,这也是 VC++ 2010 之后 MFC 新工程的默认状态。

4. 从 .dsp 老工程到新版 Visual Studio:迁移与运行库

4.1 StdAfx 预编译头的加速原理

StdAfx.hStdAfx.cpp是 Visual C++ 6.0 自动生成的预编译头文件,前者集中包含 MFC 核心头文件:

#include <afxwin.h> #include <afxext.h> #include <afxdisp.h> #include <afxdtctl.h>

预编译机制把这组头文件翻译成编译器内部状态并缓存为.pch,后续每个.cpp文件都复用这份缓存。其余源文件的第一行必须是#include "stdafx.h",否则编译会报:

Fatal Error C1010: unexpected end of file while looking for precompiled header directive

排查 C1010 时不需要改工程设置,只需要确认两件事:一是工程选项中/Yu指定的头文件名与#include的写法完全一致;二是StdAfx.cpp单独使用/Yc负责生成预编译头。如果StdAfx.cpp被误删,会出现大量链接错误,例如LNK2001: unresolved external symbol __beginthreadex,这类问题很容易被误判为运行库缺失。

4.2 RdPass.dsw 转 .sln 的兼容性处理

用 Visual C++ 2010 学习版直接打开RdPass.dsw,转换向导会生成新的.sln.vcxproj。实际迁移时,有四个地方需要手工干预。

第一是字符集。VC++ 6.0 工程默认多字节字符集,VC++ 2010 的 MFC 模板默认 Unicode。如果代码里写死了char*CString的隐式转换,在 Unicode 下会大量触发 C2664 编译错误:

CString strTitle = _T("RdPass"); // 用 _T() 包裹字符串字面量 SetWindowText(strTitle);

第二是for循环作用域。VC++ 6.0 的循环变量在循环结束后仍然可见,VC++ 2010 默认开启/Zc:forScope,在循环外引用循环变量会直接报错。修复方式是编辑代码,把变量定义移到循环外。

第三是派生文件清理。RdPass.plg是编译器日志,RdPass.clw是类向导数据库,迁移前直接删除,新版 VS 不需要它们,保留反而会把项目目录弄乱。

第四是 MFC 库的链接方式。在工程属性中检查“使用 MFC”是否设置为“使用共享 DLL 中的 MFC”,老工程从另一台机器拷贝过来后,这里的设置经常会丢。

4.3 编译产物依赖的 Visual C++ 运行库

vc生成随机密码.zip自带Release目录,说明压缩包里包含预编译好的可执行文件。VC++ 6.0 下如果以/MD方式链接,生成的RdPass.exe依赖MFC42.dllMSVCRT.dll;改成/MT静态链接后可以在干净 Windows 上运行,但文件体积会变大。用dumpbin可以查看具体依赖:

dumpbin /dependents Release\RdPass.exe

dumpbin是 Visual Studio 自带的命令行工具,需要在“Visual Studio 命令提示符”中执行。输出里的MFC42.dllMSVCRT.dll表明目标机器需要安装对应运行库。不同版本的 Visual C++ 运行库独立安装、互不包含:2005 对应 8.0 版本,2008 对应 9.0,2010 对应 10.0。装错版本时,程序会弹出side-by-side configuration is incorrect的错误对话框。迁移老工程时,最好的策略是静态链接 MFC 和 CRT,彻底免除目标机器的运行库依赖。

注意:网上常见的 Visual C++ 运行库整合包把所有版本捆在一起安装,可以解决大部分依赖问题,但静态链接仍然是发布工具类程序更可控的方案,尤其适用于需要分发给多台机器的场景。

4.4 .dsp 工程中常见的三个链接错误

老工程迁移时,下面的链接错误出现频率最高,对应的处理方式也相对固定:

错误信息原因处理方法
LNK2005 xxx already defined全局变量在多个 .cpp 中重复定义把定义改为extern声明,并用唯一 .cpp 提供定义
LNK2001 unresolved external symbol链接器没有找到 MFC 库检查工程属性中 MFC 使用方式是否为共享 DLL
LNK2019 unresolved external symbol "class CRdPassApp theApp"RdPass.cpp 中的 CWinApp 派生类定义被排除出编译在工程文件列表中确认 RdPass.cpp 存在

有些老工程会在源码里写#pragma comment(lib, "winmm.lib")这样的指令,迁移后依然有效。但如果新版 VS 报找不到对应.lib,一般是 Windows SDK 版本升级后旧库文件位置发生了变更,直接从旧 SDK 目录里拷贝一份到工程文件夹即可。

5. 用 CryptGenRandom 替换 rand():随机密码的熵与验证

5.1 用信息熵量化密码强度

RdPass 生成的密码是否够强,不能凭肉眼判断,要用信息熵量化。单字符熵值公式是H = log2(N),其中N是字符池大小。按 RdPass 的四种勾选组合计算:

字符池组合池大小 N单字符熵12 位总熵
仅小写字母264.70 bit56.4 bit
小写 + 数字365.17 bit62.0 bit
小写 + 大写 + 数字625.95 bit71.4 bit
大小写 + 数字 + 22 个特殊字符846.39 bit76.7 bit

可以看出,即使四个字符集全部勾选,12 位密码的熵也只有 76.7 bit,距离常见的 80 bit 安全基线还有差距。如果企业口令策略要求熵值 80 bit 以上,密码长度至少要到 13 位。

5.2 Crypto API 获取高熵随机数

生产环境不应依赖 CRT 的rand(),推荐改用 Windows Crypto API:

#include <wincrypt.h> BOOL GenerateRandomBytes(BYTE* pOut, DWORD dwLen) { HCRYPTPROV hProv = 0; BOOL bResult = CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT); if (!bResult) return FALSE; bResult = CryptGenRandom(hProv, dwLen, pOut); CryptReleaseContext(hProv, 0); return bResult; }

CryptAcquireContext第五个参数传CRYPT_VERIFYCONTEXT时,函数只获取临时默认上下文,不创建持久密钥文件,这是只取随机数的推荐用法。CryptGenRandom的第二个参数是字节数,第三个参数是输出缓冲区指针。改造 RdPass 时,把原来的rand() % nPoolSize替换为:

int nRand = 0; if (GenerateRandomBytes((BYTE*)&nRand, sizeof(nRand))) { nIndex = nRand % nSize; } else { nRand = rand(); // 降级分支,保证功能可用 nIndex = (int)((double)(nRand >> 4) / 2048.0 * nSize); }

降级分支存在的意义是防止系统 API 调用失败时程序直接崩溃,但日志中要标记降级发生,最典型的失败原因是当前用户账户没有获取 CSP 上下文的权限。如果后续希望兼容新版 Windows,可以换用BCryptGenRandom(0, pOut, dwLen, BCRYPT_USE_SYSTEM_PREFERRED_RNG),接口更简洁,且不需要手动释放上下文。

5.3 连续生成结果的验证方法

改造完成后,可以用一个简单的手段验证随机质量:连续调用 10 次生成长度为 64 的密码,检查两组相邻密码的相同位置上出现相同字符的占比。理想情况下,这个比例应该接近 1/84 的量级,也就是约 1.2%。如果相同位置的重复比例明显高于该值,说明随机数的低熵问题没有真正解决,需要检查代码是否在CryptAcquireContext失败后静默走了rand()降级分支——这是这类工具最容易出现的哑故障。

验证脚本用 Python 可以快速完成:

import math pool_size = 84 repeat_rate = 0.05 # 从实际输出统计得到 entropy = 64 * math.log2(pool_size) print(f"理论熵值 {entropy:.1f} bit,同位置重复率阈值约 {1/pool_size:.3f}") print(f"实测重复率 {repeat_rate:.3f}" + (",通过" if repeat_rate < 0.02 else ",需检查降级分支"))

如果实测重复率高于 2%,优先检查CryptAcquireContext调用前后的GetLastError()返回值,并把降级路径里的rand()分支替换为死循环重试或直接返回失败,避免在安全工具里让弱的随机数被业务逻辑意外接受。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询