最近在查一个Bug,好几天下来一直没有头绪,而且重现条件挺看RP……查了一阵子遇到阻碍,感觉没有什么思路,总之先加了一些日志,顺便在这里记录一下状况。

代码的简要结构是这样的:

class CReceiver : public IReceiver    // 定义在Dll_A中
{
    CCheck *m_pCheck;
public:
    CReceiver(CCheck *ptr) : m_pCheck(ptr) { }
    virtual void OnReceiver() { m_PCheck->OnReceiver(); }
};

class CCheck : public ICheck        // 定义在Dll_A中
{
    CThread     m_pUpdateThread;    // 线程函数为UpdateData
    CReceiver   m_pReceiver;
    list m_List;             // 塞了一些数据的容器
public:
    void OnReceiver();              // 接收数据,整个OnReceiver会加锁
    void UpdateData();              // 更新数据到其他地方,涉及到m_List的地方有上锁
    virtual bool Start(int id);
    virtual void Stop(int id);
};

class CService        // 定义在Dll_B中
{
    IReceiver *m_Recv;
public:
    bool RegisterRecv(IReceiver *recv) { if (!m_Recv) m_Recv = recv; }
    void UnregisterRecv()              { m_Recv = nullptr; }
    void OnReceive()                   { if (m_Recv) m_recv->OnReceive(); }
};

其中 CService 从网络中监听并接收数据,如果 m_Recv 存在,则将数据转发给 m_Recv,m_Recv 只是简单的传递调用 CCheck::OnReceiver(),后者提取并更新数据。而 CCheck 中的另一个线程
m_pUpdateThread,则定时从列表 m_List 中读取出数据并用于别处。在应用程序(exe)的一个单例类 Manager 中定义了 CCheck 的一个实例,同时也维护了一张类似于 m_List 的表,并进行 Start()/Stop() 的控制。在 Manager 中释放资源时,会检查 Manager 中维护的列表,并对其逐一调用 Stop(),最后调用 delete 释放内存。

extern CService* GetService();
bool CCheck::Start(int id)
{
    if(!m_pReceiver) {
        CService *pService = GetService();    // 获得与CCheck关联的CService
        if (!pDeviceService)
            return false;
        m_pReceiver = new CReceiver(this);
        pService->RegisterRecv(m_pReciever);
    }
    // 初始化其他数据,根据id在m_List中添加/初始化一些数据
    if (!m_pUpdateThread) {
        m_pUpdateThread = new CThread(this);
        m_pUpdateThread->CreateThread();
    }
    return true;
}
void CCheck::Stop(int id)
{
    if (m_List.size() <= 1) { CService *pService = GetService(); // 获得与CCheck关联的CService if (pService) pService->UnregisterRecv();
        if (m_pReceiver)
            delete m_pReceiver, m_pReciever = nullptr;
        if(m_pUpdateThread)
            m_pUpdateThread->Kill(), delete m_pUpdateThread, m_pUpdateThread = nullptr;
    }
    // 移除m_List中的数据
}

因为 CCheck 的 Start()/Stop() 都是在应用程序的主线程中调用的,因此也不会产生额外的多线程问题。CCheck 的析构函数与 Stop() 类似,反注册接收、停止更新线程并释放资源。

然后说一下 Dump 下来的情况:

  1. 异常发生在 CService::OnReceive() -> m_Recv->OnReceive() -> CCheck::OnReceive() 中, 在进入 CCheck::OnReceive() 后读取成员变量时异常,因为 this 指向了无效的内存。
  2. 看主线程的情况,发生异常时正在执行 CCheck::Start() 之后的其他代码,推测是调用 Start() 后开始接收数据了才导致的问题。
  3. CService、CService::m_Recv 这两个类的自身都完好,数据和 vptr 都正常,只是 m_Recv 指向 CCheck 的指针的值不对了。
  4. 检查 Manager 中存放的 CCheck,发现其中的数据都正常,但是这里的 CCheck::m_pReceiver 与 CService 中的 m_Recv 不是同一个,同时 Manager 中的 CCheck::m_pUpdateThread 代表的线程也是实际存在的(还在跑着)。
  5. 有一个情况,在其中一个 Dump 中,CService::m_Recv 里指向的 CCheck 数据块中,虽然其他成员包括 vptr 依旧是无效的,但是 CCheck::m_pReceiver 还是正确地指回了 CService::m_Recv(构造的时候进行了互指),这个原因应该是 CCheck 的基类析构函数是非虚导致的(这特么也是个大坑= =b);然后另一个 Dump 中 CService::m_Recv 指向的数据就完全乱七八糟了,指针的地址也不像是在同一个堆范围中(前面那个 Dump 地址值还是比较靠近的)。

一些推测:

  • 因为 CService::m_Recv 指向的 CCheck 的内存已释放,而且有两个有效的 CReceiver 对象,这样应该是在未调用 Stop() 的情况下直接析构了 CCheck(然后重新 new 了一个 CCheck)。但是 Manager 有管理着类似的列表,这样的话怎么绕过 Manager 发起的 Stop() 调用而进行析构就是个问题(看了看 Manager 还没发现什么问题)。
  • 同时也能推测 CService 中旧的 m_Recv 没有删除,因为新的 CReceiver 并没有设置成功。
  • 如果说 CService 因为上一次 Stop() 时没有反注册,那么下次 Start() 也不会有任何问题;如果期间 CCheck 析构,则会立即导致异常(接收数据时 m_pReceiver 无效,而且未停止的 Update 线程也会贡献一个异常)。
  • 如果 GetService() 返回空(其实不太可能,函数只是查个 map,而 map 所在类(单例)也不会在运行中途析构,从 map 移除数据也只发生在其所在类的析构函数中)导致反注册失败的话,那么当在 Stop() 中删除掉 m_pReceiver 时 CService 那边就会产生异常,而且异常会发生在 m_Recv->OnReceive() 处,因为 vptr 也是无效的,与现状不符。
  • 不太确定 Release 下堆空间回收后仍能访问到完好数据的可能性,不过就算这样也不好解释 Start() 后才发生异常的情况。
  • 如果是 Start() 直接设置异常指针没的话。。。显然不太可能 = =
  • 应用程序单例类 Manager 中也有相似的管理过程,不过真正的操作都是传递到 CCheck 来完成的,目前还没有在 Manager 中发现有异常的地方。
  • (这里的锁是 CRITICAL_SECTION,析构时并未进行删除操作(又一个坑。。。),调试中发现有异常,但非调试状态是否有影响还不清楚)

总之考虑了不少可能,但似乎都无法还原 Dump 中的现场,先做个记录,有了新进展后再更新吧。

====================== 7月15日更新 ======================
刚写好的第二天就有了进展什么的。。。只能挤出一个尴尬而又不失礼貌的微笑了。。。

偶然之间又入手了一个 Dump,浏览了一遍发现并没有太大差别,只是这次 CService::m_Recv 中指向 CCheck 的指针是相当完好的,正如上面所说的,因为 CCheck 的基类析构函数是非虚导致的,所以内存数据还是非常完整的,各个字段都还没有被覆盖,保留着析构之前的值,vptr 也是指向了 ICheck 的虚表。其实到这里还不算有什么新发现,毕竟这个问题以前就发现了,但是这次的 Bug 其实并不是像我想象的那样与析构非虚无关,只是我一开始没能想明白这个非虚的漏洞和这次的异常之间的关联。

事实是这样的:
在 Start() 中的初始化 m_pReceiver 和 m_pUpdateThread 之间,有对其他数据初始化的过程,而这有可能失败并使 Start() 返回 false。而 Manager 中是在 Start() 返回 true 后才将 id 添加进自身的列表中的,这个时候便埋下了隐患。同时配合 CCheck::~CCheck() 未被调用到的情况,就会发现问题:

  1. Start() 返回 false,此时 CCheck::m_List 仍然是空的,但是 m_pReceiver 已经设置完毕了, m_pUpdateThread 因为中途返回而没有新建;
  2. 因为 Start() 返回 false,Manager 中的列表不会添加新的记录;
  3. 原本释放 CCheck 时,Manager 会根据自身的列表逐一对其调用 Stop(),因此正常情况下 Stop() 也能替析构函数顶着,但这个时候因为 Manager 内的表是空的,所以跳过这一步直接直接下一步的析构了;
  4. CCheck::~CCheck() 未被调用,因此 CService 的反注册没有被调用,这个时候 CService::m_Recv 便指向了已释放的 CCheck(所以也能看到此时的 CService::m_Recv 依然是正常的,因为没释放内存);
  5. 下一次重新生成新的 CCheck,并调用 Start() 后开始接收数据时,因为 CService::m_Recv 已经存在,所以新的接收注册失败了。随后引发异常。

感概一下:虽然我发现了析构的坑,但还是绕了不少弯路才找到关键的原因,分析过程中总是被 Start() 的前半部分吸引注意,而忽略了里面中途返回的分支;而且我常常会想到缓冲区溢出相关的原因上去,实际上这种应该更容易发现了,毕竟现在基本有 /gs 的安全检查在,像这次这种几乎是“精准打击”的覆盖内存应该是没可能的。所以比起考虑这种概率极小的事件,还不如把所有的代码路径组合都覆盖一遍要有效果
_(·ω·」∠)_