news 2026/11/4 12:17:43

Winsock LSP注入:协议目录、协议链与WSPStartup实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Winsock LSP注入:协议目录、协议链与WSPStartup实践

简介:《lsp注入(原理及其实现代码)》围绕LSP(语言服务提供者)在Windows系统中的COM组件机制,讲解从创建自定义DLL、注册到系统、运行时注入到拦截文件请求的完整链路,面向具备C++与COM基础、希望理解底层钩子和安全检测实现的学习者,并讨论安全检测、数据恢复、调试工具及恶意滥用等典型场景。压缩包共38个文件,约150KB,以15个cpp、7个h源码为核心,辅以vcxproj工程、sln解决方案、bat注册脚本、def导出文件及已编译dll/exe等,便于编译、注册和调试。已有1583人学习。内容覆盖LSP组件创建、注册表注册、进程注入、请求拦截等关键环节,涉及IDispatch、IUnknown与ILanguageService接口的实现思路,以及组件注册与卸载、注入与移除代码的组织方式;配合工程与脚本可辅助搭建实验环境、梳理注入流程和排错路径。适合在合法授权环境下研究该技术,也需注意使用不当可能影响系统稳定性并带来安全风险。

1. LSP 注入不是往进程里塞 shellcode:先看清它站在哪一层

很多人第一次听到 lsp注入,脑子里浮现的是远程线程、APC、内存马那一套。实际拆开看,LSP 注入走的是完全不同的路子:它不去碰别人进程的内存,而是把自己注册进系统的 Winsock 协议目录,从此每个调用socket()的进程在WSAStartup阶段都会被加载器顺带把这份 DLL 映射进去。写一个 LSP,本质上是写一个符合 Winsock 2 SPI 规范的分层服务提供者,让系统认为"这个协议栈的入口应该先经过它"。它解决的问题很具体:在不改动业务代码、不挂钩子、不打补丁的前提下,拿到进程内每一次 socket 创建、连接、收发的一手上下文。适合做协议调试、连接行为统计、长连接生命周期观测的工程师,也适合想搞懂 SPI 这套目录机制的人。门槛不在代码量,而在对协议链、句柄绑定和加载时序的理解。

2. 协议目录与协议链:LSP 能被加载的底层依据

2.1 协议目录里到底存了什么

Winsock 2 之后,系统把所有可用的传输提供者登记在一份叫协议目录的东西里。这份目录不是简单的列表,每个条目是一个WSAPROTOCOL_INFOW结构,记录了提供者 GUID、协议名、地址族、套接字类型、支持的语义标志,以及最关键的ProtocolChain字段。进程启动网络时,WSAStartup会读这份目录,按协议名匹配到条目,再顺着ProtocolChain里的编号逐个把对应的 DLL 加载进来。

这就解释了为什么 LSP 不需要"注入"动作:你只要在目录里新增一条记录,并且让它的协议链把自己的条目编号排在基础提供者前面,系统就会在匹配到该协议时主动加载你的 DLL。所谓注入,是目录机制的自然结果,不是外力强塞。

目录分两套:64 位进程读 64 位目录,32 位进程读 32 位目录(重定向到Wow6432Node路径下)。这是后面很多"一半程序好使一半不好使"问题的根源,第 5 章会细说。

ProtocolChain的结构本身很简单,三个字段:

字段含义分层提供者的取值
ChainLen链上节点个数分层为 2 及以上,基础提供者为 1
ChainEntries[0]第一个节点,即最外层自己的目录条目编号
ChainEntries[1]第二个节点下层提供者的目录条目编号

ChainLen等于 1 时表示这是基础提供者,系统直接调它的WSPStartup。等于 2 时,系统先加载ChainEntries[0]指向的 DLL,把ChainEntries[1]的信息作为参数传进去,由外层自己决定怎么把请求转下去。多层链理论上可以继续加长,但实际维护成本很高,一般一层就够。

2.2 关键字段怎么填,填错就加载不起来

WSAPROTOCOL_INFOW字段多,真正决定成败的就那么几个。我一般是以一条现成的 TCP 基础提供者条目为模板,拷贝一份再逐项改,比从零构造靠谱得多。

ProviderId换成自己生成的 GUID,安装时靠它做唯一标识,卸载也靠它定位。szProtocol是显示名,会出现在WSCEnumProtocols的结果里,起个能认出来的名字,调试时省事。dwCatalogEntryId不要手填,安装时由系统分配,装完之后要重新枚举一次把自己的真实编号拿回来,再回写到ChainEntries[0]。

语义标志位是最容易翻车的地方。dwServiceFlags1里如果原模板带了PFL_HIDDEN,必须清掉,否则条目在枚举里看不见,业务侧也匹配不到。XP1_IFS_HANDLES要保留,它表示该提供者返回的套接字句柄是真正的内核句柄,可以被WaitForSingleObject之类的接口直接等待;丢了这一位,一些依赖句柄等待的程序会出现莫名其妙的超时。XP1_CONNECTIONLESS、XP1_MESSAGE_ORIENTED这些要跟下层保持一致,否则协议行为对不上。

还有一点常被忽略:模板里如果有dwProviderReserved之类的私有字段,拷贝过来之后要清零,别把别人保留的东西带进自己的条目。

2.3 加载时序:WSPStartup 是被谁、在什么时候调的

理解调用时序,才能理解后面所有排错手段。完整的链路是这样:进程调用WSAStartup,ws2_32 读目录匹配协议条目,发现ChainLen大于 1,于是按ChainEntries[0]找到 DLL 路径,LoadLibrary加载,然后GetProcAddress("WSPStartup"),调用它,并把一个填好的WSPUPCALLTABLE和一个待填充的WSPPROC_TABLE一起传进去。

外层WSPStartup的职责就三件事:一是找到下层提供者的 DLL 路径并加载;二是调用下层的WSPStartup,拿到下层的函数表;三是把下层的每个函数指针抄进自己的WSPPROC_TABLE,把需要观测的那几个换成自己的实现,然后返回 0。

注意这个顺序:WSPStartup是在目标进程里、在WSAStartup的调用栈上执行的。这意味着此时进程的加载器锁状态、当前线程上下文都很特殊,任何重量级操作都可能出问题。第 5 章第一条坑讲的就是这个。

还有一个隐蔽点:找下层 DLL 路径不能凭感觉拼。正确做法是拿lpProtocolInfo->ProtocolChain.ChainEntries[1]这个编号,去枚举协议目录找到对应条目,再调WSCGetProviderPath取路径。如果直接写死mswsock.dll或者ws2_32.dll,在非 TCP 协议或者被其他厂商接管的环境里会立刻失败。

3. 写一个能挂上去的 LSP:导出表、句柄绑定与透传骨架

3.1 导出清单:少一个函数就是空指针崩溃

SPI 规定了三十个左右的入口函数,加上WSPStartup和几个辅助入口,全部要用__declspec(dllexport)导出,调用约定统一是WSPAPI(即__stdcall)。函数名不能改,系统是按名字GetProcAddress的。

工程上我一般开一个.def文件集中声明,比在头文件里一个个写dllexport清晰:

; SampleLsp.def —— 集中导出,避免漏写某个入口 LIBRARY SampleLsp EXPORTS WSPStartup WSPAccept WSPBind WSPCleanup WSPCloseSocket WSPConnect WSPCreateSocket WSPGetPeerName WSPGetSockName WSPGetSockOpt WSPIoctl WSPRecv WSPRecvFrom WSPSend WSPSendTo WSPSetSockOpt WSPShutdown WSPSocket ; 其余入口按同样方式补齐,一个都不能缺

逻辑说明:.def只负责导出名字,不产生代码。WSPPROC_TABLE里每个字段都必须指向一个有效函数,哪怕它只是原样转给下层。如果某个入口没导出,GetProcAddress拿到空指针,系统填进表里,业务侧第一次调用到它时就是访问违例,现象是程序毫无征兆地闪退,栈里还看不出跟你的 DLL 有直接关系。

参数说明:LIBRARY后面的名字要和 DLL 文件名一致,否则某些加载路径下会找不到导出。

3.2 WSPStartup 里的三步:找路径、调下层、填表

这是整个 LSP 的核心,也是最容易写得不对的地方。下面是我一般会用的骨架:

// 全局保存下层的函数表,所有透传都从这里转 static WSPPROC_TABLE g_next; static HMODULE g_hNextDll = NULL; int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { if (lpProtocolInfo == NULL || lpProtocolInfo->ProtocolChain.ChainLen <= 1) return WSAEPROVIDERFAILEDINIT; // 第一步:拿下一层的目录编号,枚举出它的条目 DWORD nextId = lpProtocolInfo->ProtocolChain.ChainEntries[1]; WSAPROTOCOL_INFOW nextInfo; DWORD bufLen = sizeof(nextInfo); if (WSCEnumProtocols(&nextId, &nextInfo, &bufLen, NULL) == SOCKET_ERROR) return WSAEPROVIDERFAILEDINIT; // 第二步:由下一层的 ProviderId 取 DLL 路径,加载并调用它的 WSPStartup WCHAR dllPath[MAX_PATH] = {0}; int pathLen = MAX_PATH; if (WSCGetProviderPath(&nextInfo.ProviderId, dllPath, &pathLen, NULL) == SOCKET_ERROR) return WSAEPROVIDERFAILEDINIT; if (g_hNextDll == NULL) { g_hNextDll = LoadLibraryW(dllPath); if (g_hNextDll == NULL) return WSAEPROVIDERFAILEDINIT; } typedef int (WSPAPI *PFN_WSPSTARTUP)( WORD, LPWSPDATA, LPWSAPROTOCOL_INFOW, WSPUPCALLTABLE, LPWSPPROC_TABLE); PFN_WSPSTARTUP pfnStartup = (PFN_WSPSTARTUP)GetProcAddress(g_hNextDll, "WSPStartup"); if (pfnStartup == NULL) return WSAEPROVIDERFAILEDINIT; // 注意:传给下层的是 nextInfo,不是 lpProtocolInfo int rc = pfnStartup(wVersionRequested, lpWSPData, &nextInfo, UpcallTable, &g_next); if (rc != 0) return rc; // 第三步:整表抄过来,之后按需替换 *lpProcTable = g_next; lpProcTable->lpWSPConnect = WSPConnect; // 观测连接 lpProcTable->lpWSPCloseSocket = WSPCloseSocket; // 观测关闭 return 0; }

逻辑说明:第一步用WSCEnumProtocols把下层条目查出来,是因为协议链里只存了编号,没有存路径。第二步调WSCGetProviderPath拿真实 DLL 路径,这样不依赖任何写死的文件名。第三步先把整张表原样拷过来,保证所有入口都是可用的,再挑几个覆盖成自己的实现。

参数说明:WSCEnumProtocols的第一个参数是输入输出参数,传入起始编号,返回时更新为下一个编号;bufLen传结构大小,返回时更新为实际需要的大小,如果缓冲区不够会返回SOCKET_ERROR且错误码是WSAEFAULT。WSCGetProviderPath的路径缓冲区必须用宽字符,长度按字符数传。

一个必须强调的点:传给下层WSPStartup的第三个参数必须是下层自己的WSAPROTOCOL_INFOW,不能把lpProtocolInfo原样递下去。递错了,下层会认为自己是分层提供者,继续往下找链,形成自引用,表现是WSAStartup卡住或者直接崩。

3.3 透传函数:每一行都要原样传递 errno

被替换的入口函数写法极其机械,但有一条铁律不能破:下层返回的lpErrno必须原样带回去。

int WSPAPI WSPConnect( SOCKET s, const struct sockaddr FAR *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { // 这里可以拿到连接目标和当前线程,做只读观测 // 观测代码必须短、必须不抛异常、必须不阻塞 int rc = g_next.lpWSPConnect(s, name, namelen, lpCallerData, lpCalleeData, lpSQOS, lpGQOS, lpErrno); // 不要再改 lpErrno,下层写什么就是什么 return rc; }

逻辑说明:函数体里只做两件事——可选的只读观测,以及对下层的原样转发。返回值直接透传,lpErrno交给下层去写。

参数说明:lpErrno是 SPI 的错误输出约定,跟 BSD 风格的全局errno不同,必须由实现方显式写入。如果我们在这层自己改写了错误码,上层的业务代码就会拿到错误的错误值,排查时会被误导到完全不相干的方向。

WSPCloseSocket也是常被替换的一个,用来统计连接寿命。注意它的语义跟closesocket不同,接收两个参数:SOCKET s和LPINT lpErrno。顺带一提,WSPCreateSocket的签名里带着lpProtocolInfo,可以在创建时就把协议上下文记下来,为后面按连接聚合日志做准备。

4. 注册、排序与卸载:让协议目录真正生效

4.1 安装:一次调用要准备好三样东西

代码编译成 DLL 只是第一步,不注册进目录,系统根本不知道它存在。

// 安装示例:以现有 TCP 条目为模板改写 int InstallLsp(const wchar_t* dllPath) { GUID guid = SAMPLE_LSP_GUID; // 自己生成的固定 GUID,卸载要用同一个 // 1) 先枚举出一条 TCP 基础提供者当模板 WSAPROTOCOL_INFOW proto[64]; DWORD bufLen = sizeof(proto); int count = WSCEnumProtocols(NULL, proto, &bufLen, NULL); if (count == SOCKET_ERROR) return -1; WSAPROTOCOL_INFOW mine = {0}; int found = 0; for (int i = 0; i < count; ++i) { // 找 IPv4、流式、基础(ChainLen==1)的 TCP 条目 if (proto[i].iAddressFamily == AF_INET && proto[i].iSocketType == SOCK_STREAM && proto[i].ProtocolChain.ChainLen == 1) { mine = proto[i]; found = 1; break; } } if (!found) return -1; // 2) 改写关键字段 mine.ProviderId = guid; mine.dwCatalogEntryId = 0; // 交给系统分配 mine.dwServiceFlags1 &= ~PFL_HIDDEN; // 必须可见 mine.dwServiceFlags1 |= XP1_IFS_HANDLES; // 保留句柄语义 wcscpy_s(mine.szProtocol, L"Sample LSP over TCP"); mine.ProtocolChain.ChainLen = 2; mine.ProtocolChain.ChainEntries[0] = 0; // 占位,装完回填 mine.ProtocolChain.ChainEntries[1] = proto[i].dwCatalogEntryId; // 3) 安装 INT err = 0; if (WSCInstallProvider(&guid, dllPath, &mine, 1, &err) == SOCKET_ERROR) return err; // 4) 重新枚举,把自己真实的编号写回 ChainEntries[0] bufLen = sizeof(proto); count = WSCEnumProtocols(NULL, proto, &bufLen, NULL); for (int i = 0; i < count; ++i) { if (IsEqualGUID(proto[i].ProviderId, guid)) { mine = proto[i]; mine.ProtocolChain.ChainEntries[0] = proto[i].dwCatalogEntryId; WSCUpdateProvider(&guid, dllPath, &mine, 1, &err); break; } } return 0; }

逻辑说明:整个流程是"拷贝模板 → 改身份 → 装进去 → 回填自引用编号"。第四步不能省,因为ChainEntries[0]必须指向自己的目录编号,而编号只有系统知道。少这一步,协议链的首节点指向 0,加载时找不到 DLL,WSAStartup直接失败。

参数说明:WSCInstallProvider的第二个参数是 DLL 的完整路径,必须是系统能访问到的绝对路径,放在用户目录下在某些服务账户里会读不到。最后一个参数是错误输出,失败时先看它。

4.2 排序:装了不等于用得上

安装只是把条目加进目录,能不能被业务命中,取决于协议匹配时的优先级。这一步用WSCWriteProviderOrder完成,传入一个目录编号数组,顺序即优先级。

// 把我们的条目排到 TCP 之前 int PromoteLsp(DWORD myCatalogId, DWORD tcpCatalogId) { DWORD order[2] = { myCatalogId, tcpCatalogId }; INT err = 0; if (WSCWriteProviderOrder(order, 2, &err) == SOCKET_ERROR) return err; return 0; }

逻辑说明:数组里靠前的编号优先被匹配。如果只想影响特定协议,就把目标协议的基础条目编号也放进数组,其余保持原顺序。

参数说明:这个调用需要管理员权限,普通用户下会返回WSAEACCES。写完顺序后,新起的进程立即生效,已经在跑的进程不会重新读目录,所以测试时记得重启目标程序。

4.3 卸载:不清理干净,下次重装就是灾难

卸载要按相反顺序做:先从顺序表里摘掉、再删条目、最后删 DLL 文件。

int UninstallLsp(DWORD myCatalogId, DWORD tcpCatalogId) { INT err = 0; // 1) 先把顺序恢复成 TCP 优先 DWORD order[1] = { tcpCatalogId }; WSCWriteProviderOrder(order, 1, &err); // 2) 按 GUID 删除条目 GUID guid = SAMPLE_LSP_GUID; if (WSCDeinstallProvider(&guid, &err) == SOCKET_ERROR) return err; return 0; }

逻辑说明:顺序表里如果还留着已经删掉的编号,后续枚举会出问题,所以先改顺序再删条目。删除后建议重启一次再验证,因为有些进程还持有旧 DLL 的映射,文件删不掉。

参数说明:WSCDeinstallProvider只认 GUID,所以 GUID 必须固化在代码里,不能用运行时随机生成的值——随机 GUID 装上去,重启后你自己都找不回来。

5. 避坑与排查:五类现场和它们的成因

5.1 现象:程序一联网就闪退,事件日志里只有模块名

原因:WSPPROC_TABLE里有字段是空指针。最常见的是某个入口忘了导出,或者*lpProcTable = g_next这行写在了替换语句之后,把已经替换好的指针又覆盖回去了。

解决:写一个自检函数,在WSPStartup返回前遍历整张表,逐个判断是否为空,为空就返回WSAEPROVIDERFAILEDINIT。宁可加载失败,也不要让业务进程在随机时刻崩。返回失败至少现象是"网络不可用",比闪退好定位得多。

5.2 现象:一调WSAStartup就卡死,或者进程挂起在加载器里

原因:在DllMain或者WSPStartup里做了LoadLibrary之外的重量级操作,比如起线程、等锁、写文件、调CreateProcess。WSPStartup处在加载器锁的保护范围内,此时再去加载别的模块或者等待其它线程,很容易形成死锁。

解决:把所有初始化推迟到第一次真正的 SPI 调用里做,用一次性的原子标志位控制。WSPStartup里只允许做三件事:查路径、LoadLibrary下层、填表。日志也别在这里写,改成内存环形缓冲区,后面再落盘。

5.3 现象:连接能建,但收发的数据对不上,或者程序报句柄无效

原因:在实现里直接调了 ws2_32 的send/recv,而不是走g_next的函数表。这会形成递归:你的WSPSend调send,send又走协议链回到你的WSPSend,栈很快溢出。另一个可能是XP1_IFS_HANDLES被清掉了,业务侧拿句柄去做等待时行为异常。

解决:所有对外操作一律走g_next,一条都不能例外。语义标志位以模板为准,只做必要的增减,改完用WSCEnumProtocols把条目打出来逐字段对比。

5.4 现象:64 位程序正常,32 位程序完全没反应,或者反过来

原因:只在一套目录里注册了。32 位进程读的是重定向后的目录,与 64 位是两份独立的数据。用 64 位安装程序装完,32 位程序根本看不到你的条目。

解决:DLL 要编译两份,安装代码也要跑两遍,分别用 32 位和 64 位进程执行。验证时用两种位数的程序各测一次,别只测自己手上那个。

5.5 现象:卸载重装之后,协议目录里出现多条同名条目,网络时好时坏

原因:上次卸载没成功,或者用了新的 GUID 重新安装,导致旧条目残留在目录里,协议链指向已删除的 DLL。系统加载时找不到文件,整条链失效。

解决:安装前先按 GUID 尝试卸载一次,忽略返回值;安装后用WSCEnumProtocols按 GUID 统计条目数量,大于 1 就说明有残留。实在清不掉,就在测试机上恢复到注册表快照,别在污染过的目录上继续叠加。

6. 进阶验证:用一张对照表判断注入到底挂上没有

写完代码最怕的是"看起来生效了"。我习惯用一张对照表逐项确认,而不是靠某个程序能不能上网来猜。

验证项期望结果观测方法
目录条目存在按 GUID 能枚举到 1 条WSCEnumProtocols后过滤 GUID 计数
协议链自引用正确ChainEntries[0]等于自身编号打印条目全部字段比对
顺序优先级生效自身编号排在基础条目之前WSCEnumProtocols返回顺序即优先级
DLL 被真实加载目标进程模块列表里有你的 DLL枚举目标进程模块,按名字匹配
WSPStartup被调用每次进程启动恰好一次内存计数器加一次,退出时落盘
32/64 位都覆盖两种位数的进程都能看到分别用两种程序各测一次

前四项是静态检查,装完不用跑业务就能确认。第五项是动态确认,能证明加载链路真的走通了,而不是目录里躺着一条没人用的记录。第六项最容易被跳过,但它是"一半程序出问题"的唯一有效防线。

调试阶段还有个小技巧:在WSPStartup里把wVersionRequested、传入的ChainEntries[1]、以及最终*lpProcTable的地址都记进环形缓冲区,进程退出时一次性写文件。这样即使进程崩了,也能从遗留的日志片段里看出崩在哪一步。不要在这个阶段用调试器附加目标进程,加载时序会被打乱,很多问题反而复现不出来。

从那以后我每次动 LSP 代码,都强制先在虚拟机里打快照,再按上面这张表从第一行走到最后一行,任何一行不过就不往下走。协议目录一旦被写坏,排查成本远高于重装一遍系统,这个后悔药是真的贵。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 12:16:58

应用为何都变成了仪表盘

一个应用刚开始时&#xff0c;往往只做一件事。打开&#xff0c;用&#xff0c;关掉。干净利落。但过不了多久&#xff0c;有人提议加个分析面板&#xff0c;接着是筛选器、侧边栏、工作区、团队角色、通知、动态流、小部件&#xff0c;最后&#xff0c;仪表盘顺理成章地出现了…

作者头像 李华
网站建设 2026/11/4 12:16:21

东莞品牌设计服务商怎么选?省心不踩坑

东莞品牌设计服务商怎么选?省心不踩坑&#xff0c;这是很多东莞制造企业和外贸品牌方在产品升级、品牌出海阶段最关心的问题。东莞作为制造业重镇&#xff0c;工厂资源丰富&#xff0c;但要把一个产品从想法变成卖得动的商品&#xff0c;光有生产能力和品牌意识还不够&#xf…

作者头像 李华
网站建设 2026/11/4 12:15:26

【人工智能驱动的电催化研究-01】人工智能是什么?AI、机器学习、深度学习和神经网络之间有什么区别?

第一节课&#xff1a;人工智能是什么&#xff1f;AI、机器学习、深度学习和神经网络之间有什么区别&#xff1f; 假设你正在研究一种析氢电催化剂。为了提高性能&#xff0c;你尝试改变金属组成、合成温度和处理时间&#xff0c;随后测量不同样品的电化学性能。 随着实验增加…

作者头像 李华
网站建设 2026/11/4 12:14:38

消防维保之——(防排烟系统)(一)

一、排烟系统组成 板式排烟口、远程执行机构&#xff0c;排烟防火阀、柔性接头、排烟风机、风机控制柜、排烟管道、挡烟垂壁、电动排烟窗。二、防烟系统组成 常闭式加压送风口、送风管道、防火阀、柔性接头、送风风机、风机控制柜。三、打开排烟口联动启动排烟风机 确认火灾报警…

作者头像 李华
网站建设 2026/11/4 12:14:36

【Linux】EXT2文件系统软硬链接

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 12:13:57

机器学习:多元线性回归正规方程法公式推导补充

多元线性回归正规方程法公式推导详解&#xff1a;从 WTXBW^T X BWTXB 到 XWXWXW 的转换逻辑 很多人在推导多元线性回归正规方程法时&#xff0c;都会卡在同一个地方&#xff1a;预测值公式为什么一会儿写成 WTXBW^T X BWTXB&#xff0c;一会儿又变成 XWXWXW&#xff1f;本文…

作者头像 李华