一、基于时间的沙盒规避技术
沙盒模拟通常持续很短的时间,因为沙盒加载了数千个样本。仿真 时间很少超过3-5分钟。因此,恶意软件可以利用这一事实来避免检测:它可能会执行 在开始任何恶意活动之前长时间延迟。
为了抵消这种情况,沙盒可以实现操纵时间和执行延迟的功能。沙箱具有睡眠跳过功能,可将延迟替换为非常短的值。这应该强制恶意软件启动 它在分析超时之前的恶意活动。
但是,这也可用于检测沙盒。
一些指令和API函数的执行时间也存在一些差异, 可用于检测虚拟环境。
1.延迟执行
执行延迟用于避免在模拟期间检测到恶意活动。
1.1简单的延迟操作
代码语言:javascript复制int iResult;
DWORD timeout = delay;
DWORD OK = TRUE;
SOCKADDR_IN sa = { 0 };
SOCKET sock = INVALID_SOCKET;
do {
memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
sa.sin_addr.s_addr = inet_addr("8.8.8.8");
sa.sin_port = htons(80);
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) {
OK = FALSE;
break;
}
// setting socket timeout
unsigned long iMode = 1;
iResult = ioctlsocket(sock, FIONBIO, &iMode);
iResult = connect(sock, (SOCKADDR*)&sa, sizeof(sa));
if (iResult == false) {
OK = FALSE;
break;
}
iMode = 0;
iResult = ioctlsocket(sock, FIONBIO, &iMode);
if (iResult != NO_ERROR) {
OK = FALSE;
break;
}
// fd set data
fd_set Write, Err;
FD_ZERO(&Write);
FD_ZERO(&Err);
FD_SET(sock, &Write);
FD_SET(sock, &Err);
timeval tv = { 0 };
tv.tv_usec = timeout * 1000;
// 检查套接字是否准备就绪,此调用应占用超时毫秒
select(0, NULL, &Write, &Err, &tv);
if (FD_ISSET(sock, &Err)) {
OK = FALSE;
break;
}
} while (false);
if (sock != INVALID_SOCKET)
closesocket(sock);
代码语言:javascript复制VOID CALLBACK TimerFunction(UINT uTimerID, UINT uMsg, DWORD_PTR dwUser, DWORD_PTR dw1, DWORD_PTR dw2)
{
bProcessed = TRUE;
}
VOID timing_timeSetEvent(UINT delayInSeconds)
{
UINT uResolution;
TIMECAPS tc;
MMRESULT idEvent;
timeGetDevCaps(&tc, sizeof(TIMECAPS));
uResolution = min(max(tc.wPeriodMin, 0), tc.wPeriodMax);
idEvent = timeSetEvent(
delayInSeconds,
uResolution,
TimerFunction,
0,
TIME_ONESHOT);
while (!bProcessed){
Sleep(0);
}
timeKillEvent(idEvent);
timeEndPeriod(uResolution);
}
1.2 使用任务调度程序延迟执行
此方法既可用于延迟执行,也可用于逃避沙盒跟踪:
代码语言:javascript复制$tm = (get-date).AddMinutes(10).ToString("HH:mm")
$action = New-ScheduledTaskAction -Execute "some_malicious_app.exe"
$trigger = New-ScheduledTaskTrigger -Once -At $tm
Register-ScheduledTask TaskName -Action $action -Trigger $trigger
1.3 仅在特定日期运行
恶意软件样本可能会检查当前日期,并仅在特定日期执行恶意操作。例如 这种技术被用于Sazoora恶意软件, 检查当前日期并验证该日期是 16 日、17 日还是 18 日 给定月份。
2.睡眠跳过检测
这种类型的技术通常针对监视器睡眠跳过功能和其他时间操纵 可在沙盒中使用的技术,以跳过恶意软件执行的长时间延迟。
2.1 使用不同方法的并行延迟
这些技术背后的想法是并行执行不同类型的延迟并测量经过的时间。
代码语言:javascript复制DWORD StartingTick, TimeElapsedMs;
LARGE_INTEGER DueTime;
HANDLE hTimer = NULL;
TIMER_BASIC_INFORMATION TimerInformation;
ULONG ReturnLength;
hTimer = CreateWaitableTimer(NULL, TRUE, NULL);
DueTime.QuadPart = Timeout * (-10000LL);
StartingTick = GetTickCount();
SetWaitableTimer(hTimer, &DueTime, 0, NULL, NULL, 0);
do
{
Sleep(Timeout/10);
NtQueryTimer(hTimer, TimerBasicInformation, &TimerInformation, sizeof(TIMER_BASIC_INFORMATION), &ReturnLength);
} while (!TimerInformation.TimerState);
CloseHandle(hTimer);
TimeElapsedMs = GetTickCount() - StartingTick;
printf("Requested delay: %d, elapsed time: %dn", Timeout, TimeElapsedMs);
if (abs((LONG)(TimeElapsedMs - Timeout)) > Timeout / 2)
printf("Sleep-skipping DETECTED!n");
2.2 使用不同的方法测量时间间隔
我们需要执行将在沙盒中跳过的延迟,并使用不同的方法测量经过的时间。 而 Cuckoo 监视器则钩住了 GetTickCount()、GetLocalTime()、GetSystemTime() 和 让他们返回跳过的时间,我们仍然可以找到没有处理的时间测量方法监控:
代码语言:javascript复制LARGE_INTEGER StartingTime, EndingTime;
LARGE_INTEGER Frequency;
DWORD TimeElapsedMs;
QueryPerformanceFrequency(&Frequency);
QueryPerformanceCounter(&StartingTime);
Sleep(Timeout);
QueryPerformanceCounter(&EndingTime);
TimeElapsedMs = (DWORD)(1000ll * (EndingTime.QuadPart - StartingTime.QuadPart) / Frequency.QuadPart);
printf("Requested delay: %d, elapsed time: %dn", Timeout, TimeElapsedMs);
if (abs((LONG)(TimeElapsedMs - Timeout)) > Timeout / 2)
printf("Sleep-skipping DETECTED!n");
代码语言:javascript复制ULONGLONG tick;
DWORD TimeElapsedMs;
tick = GetTickCount64();
Sleep(Timeout);
TimeElapsedMs = GetTickCount64() - tick;
printf("Requested delay: %d, elapsed time: %dn", Timeout, TimeElapsedMs);
if (abs((LONG)(TimeElapsedMs - Timeout)) > Timeout / 2)
printf("Sleep-skipping DETECTED!n");
我们还可以使用我们自己的GetTickCount实现来检测睡眠跳过。在下一个代码示例中,我们将直接从 KUSER_SHARED_DATA 结构获取即时报价计数。这样,即使 GetTickCount()函数被挂接,我们也可以获得原始的即时报价计数值:
代码语言:javascript复制#define KI_USER_SHARED_DATA 0x7FFE0000
#define SharedUserData ((KUSER_SHARED_DATA * const) KI_USER_SHARED_DATA)
#define MyGetTickCount() ((DWORD)((SharedUserData->TickCountMultiplier * (ULONGLONG)SharedUserData->TickCount.LowPart) >> 24))
// ...
StartingTick = MyGetTickCount();
Sleep(Timeout);
TimeElapsedMs = MyGetTickCount() - StartingTick;
printf("Requested delay: %d, elapsed time: %dn", Timeout, TimeElapsedMs);
if (abs((LONG)(TimeElapsedMs - Timeout)) > Timeout / 2)
printf("Sleep-skipping DETECTED!n");
2.3 使用不同的方法获取系统时间
此方法与前一种方法类似。我们尝试获取当前系统,而不是测量间隔,使用不同方法的时间:
代码语言:javascript复制SYSTEM_TIME_OF_DAY_INFORMATION SysTimeInfo;
ULONGLONG time;
LONGLONG diff;
Sleep(60000);
GetSystemTimeAsFileTime((LPFILETIME)&time);
NtQuerySystemInformation(SystemTimeOfDayInformation, &SysTimeInfo, sizeof(SysTimeInfo), 0);
diff = time - SysTimeInfo.CurrentTime.QuadPart;
if (abs(diff) > 10000000)
printf("Sleep-skipping DETECTED!n);
2.4 调用延时函数后检查延时值是否发生变化
睡眠跳过通常以较小的间隔替换延迟值来实现。 让我们看一下 NtDelayExecution 函数。延迟值使用指针传递给此函数:
代码语言:javascript复制NTSYSAPI NTSTATUS NTAPI
NtDelayExecution(
IN BOOLEAN Alertable,
IN PLARGE_INTEGER DelayInterval );
因此,我们可以检查函数执行后延迟间隔的值是否发生变化。 如果该值与初始值不同,则跳过延迟。
代码语言:javascript复制LONGLONG SavedTimeout = Timeout * (-10000LL);
DelayInterval->QuadPart = SavedTimeout;
status = NtDelayExecution(TRUE, DelayInterval);
if (DelayInterval->QuadPart != SavedTimeout)
printf("Sleep-skipping DETECTED!n");
2.5 使用绝对超时
对于执行延迟的 Nt-函数,我们可以使用相对延迟间隔或绝对超时时间。延迟间隔的负值表示相对超时,正值表示绝对超时。高级 API 函数(如 WaitForSingleObject()或 Sleep())以相对间隔运行。因此,沙盒开发人员可能不关心绝对超时并错误地处理它们。在沙盒中,这种延迟被跳过,但跳过的时间和刻度被错误地计算。这可以使用检测睡眠跳过。
代码语言:javascript复制void SleepAbs(DWORD ms)
{
LARGE_INTEGER SleepUntil;
GetSystemTimeAsFileTime((LPFILETIME)&SleepUntil);
SleepTo.QuadPart = (ms * 10000);
NtDelayExecution(TRUE, &SleepTo);
}
2.6 从另一个进程中获取时间
沙盒中的睡眠跳过不是系统范围的。因此,如果存在执行延迟,时间就会移动 在不同的过程中具有不同的速度。延迟后,我们应该同步进程并进行比较 两个进程中的当前时间。测量时间值的巨大差异表明进行了睡眠跳过。
3.虚拟机和主机中的时间测量差异
某些 API 函数和指令的执行在 VM 和通常的 主机系统。这些特性可用于检测虚拟环境。
3.1 RDTSC(使用 CPUID 强制虚拟机退出)
代码语言:javascript复制BOOL rdtsc_diff_vmexit()
{
ULONGLONG tsc1 = 0;
ULONGLONG tsc2 = 0;
ULONGLONG avg = 0;
INT cpuInfo[4] = {};
//浅试10秒
for (INT i = 0; i < 10; i )
{
tsc1 = __rdtsc();
__cpuid(cpuInfo, 0);
tsc2 = __rdtsc();
// Get the delta of the two RDTSC
avg = (tsc2 - tsc1);
}
//我们重复了这个过程10次,以确保我们的检查尽可能可靠
avg = avg / 10;
return (avg < 1000 && avg > 0) ? FALSE : TRUE;
}
3.2 RDTSC(带有 GetProcessHeap 和 CloseHandle 的锁定版本)
代码语言:javascript复制#define LODWORD(_qw) ((DWORD)(_qw))
BOOL rdtsc_diff_locky()
{
ULONGLONG tsc1;
ULONGLONG tsc2;
ULONGLONG tsc3;
DWORD i = 0;
for (i = 0; i < 10; i )
{
tsc1 = __rdtsc();
GetProcessHeap();
tsc2 = __rdtsc();
CloseHandle(0);
tsc3 = __rdtsc();
if ((LODWORD(tsc3) - LODWORD(tsc2)) / (LODWORD(tsc2) - LODWORD(tsc1)) >= 10)
return FALSE;
}
return TRUE;
}
4.使用不同的方法检查系统上次启动时间
此技术是通用操作系统查询:检查系统正常运行时间是否短和 WMI:检查上次启动时间部分中所述的技术的组合。根据用于获取系统上次启动时间的方法,测量的沙盒操作系统正常运行时间也可能 小(几分钟),或者相反,太大(几个月甚至几年),因为系统通常会恢复 从分析开始后的快照。
我们可以通过比较上次启动时间的两个值来检测沙箱,这两个值是通过 WMI 和 NtQuerySystemInformation(SystemTimeOfDayInformation 获取的:
代码语言:javascript复制bool check_last_boot_time()
{
SYSTEM_TIME_OF_DAY_INFORMATION SysTimeInfo;
LARGE_INTEGER LastBootTime;
NtQuerySystemInformation(SystemTimeOfDayInformation, &SysTimeInfo, sizeof(SysTimeInfo), 0);
LastBootTime = wmi_Get_LastBootTime();
return (wmi_LastBootTime.QuadPart - SysTimeInfo.BootTime.QuadPart) / 10000000 != 0; // 0 seconds
}
5.使用无效参数调用可能挂钩的延迟函数
NtDelayExecution 函数的第二个参数是指向延迟间隔值的指针。在内核模式下, NtDelayExecution 函数验证此指针,还可以返回以下值:
- STATUS_ACCESS_VIOLATION - 如果该值不是有效的用户模式地址
- STATUS_DATATYPE_MISALIGNMENT - 如果地址未对齐(DelayInterval & 3 != 0)
在沙盒中,可能无法正确处理 NtDelayExecution 和类似函数的输入参数。如果我们使用 DelayInterval 的未对齐指针调用 NtDelayExecution,通常它会返回STATUS_DATATYPE_MISALIGNMENT。但是,在沙盒中,延迟间隔的值可能会复制到新变量,没有适当的检查。在这种情况下,将执行延迟,返回值将被STATUS_SUCCESS。这可用于检测沙盒。
代码语言:javascript复制__declspec(align(4)) BYTE aligned_bytes[sizeof(LARGE_INTEGER) * 2];
DWORD tick_start, time_elapsed_ms;
DWORD Timeout = 10000; //10 seconds
PLARGE_INTEGER DelayInterval = (PLARGE_INTEGER)(aligned_bytes 1); //unaligned
NTSTATUS status;
DelayInterval->QuadPart = Timeout * (-10000LL);
tick_start = GetTickCount();
status = NtDelayExecution(FALSE, DelayInterval);
time_elapsed_ms = GetTickCount() - tick_start;
// 如果指针未对齐,则不应执行延迟
if (time_elapsed_ms > 500 || status != STATUS_DATATYPE_MISALIGNMENT )
printf("Sandbox detectedn");
另一方面,如果为延迟间隔设置了无法访问的地址,则应STATUS_ACCESS_VIOLATION返回代码。这也可用于检测沙盒。
代码语言:javascript复制if (NtDelayExecution(FALSE, (PLARGE_INTEGER)0) != STATUS_ACCESS_VIOLATION)
printf("Sandbox detected");
二、WMI 检测方法
Windows 管理界面 (WMI) 查询是获取操作系统和硬件信息的另一种方法。WMI 使用 COM 接口及其方法。
标准 COM 函数用于处理查询。它们按下面描述的顺序调用,可以分为 6 个步骤。
1. COM初始化:
- CoInitialize/CoInitializeEx
2. 创建所需的接口实例:
- CoCreateInstance/CoCreateInstanceEx
3. 通过具有以下功能的接口实例连接到特定服务:
- ConnectServer
4. 获取服务的方法并使用以下函数设置它们的参数:
- Method (获取方法)
- Put (设置参数)
5. 从服务中检索信息,并使用以下功能执行服务的方法。左边的函数是右边函数的代理 - 在内部调用:
- ExecQuery -> IWbemServices_ExecQuery (检索信息)
- ExecMethod -> IWbemServices_ExecMethod (执行方法)
ExecMethodAsync -> IWbemServices_ExecMethodAsync (execute method)
6. 使用以下函数检查查询结果:
- [enumerator]->Next
- [object]->Get
1.通用 WMI 查询
由于 WMI 提供了另一种收集系统信息的方法,因此它可用于执行其他文章中描述的规避技术:
BOOL number_cores_wmi() { IWbemServices *pSvc = NULL; IWbemLocator *pLoc = NULL; IEnumWbemClassObject *pEnumerator = NULL; BOOL bStatus = FALSE; HRESULT hRes; BOOL bFound = FALSE; // Init WMI bStatus = InitWMI(&pSvc, &pLoc); if (bStatus) { // 如果成功,则执行所需的查询 bStatus = ExecWMIQuery(&pSvc, &pLoc, &pEnumerator, _T("SELECT * FROM Win32_Processor")); if (bStatus) { // 从查询中获取数据 IWbemClassObject *pclsObj = NULL; ULONG uReturn = 0; VARIANT vtProp; // 迭代我们的枚举器 while (pEnumerator) { hRes = pEnumerator->Next(WBEM_INFINITE, 1, &pclsObj, &uReturn); if (0 == uReturn) break; // 获取 Name 属性的值 hRes = pclsObj->Get(_T("NumberOfCores"), 0, &vtProp, 0, 0); if (V_VT(&vtProp) != VT_NULL) { // 做我们的比较 if (vtProp.uintVal < 2) { bFound = TRUE; break; } // 释放当前结果对象 VariantClear(&vtProp); pclsObj->Release(); } } // Cleanup pEnumerator->Release(); pSvc->Release(); pLoc->Release(); CoUninitialize(); } } return bFound; } BOOL disk_size_wmi() { IWbemServices *pSvc = NULL; IWbemLocator *pLoc = NULL; IEnumWbemClassObject *pEnumerator = NULL; BOOL bStatus = FALSE; HRESULT hRes; BOOL bFound = FALSE; INT64 minHardDiskSize = (80LL * (1024LL * (1024LL * (1024LL)))); // Init WMI bStatus = InitWMI(&pSvc, &pLoc); if (bStatus) { // 如果成功,则执行所需的查询 bStatus = ExecWMIQuery(&pSvc, &pLoc, &pEnumerator, _T("SELECT * FROM Win32_LogicalDisk")); if (bStatus) { // Get the data from the query IWbemClassObject *pclsObj = NULL; ULONG uReturn = 0; VARIANT vtProp; // 迭代我们的枚举器 while (pEnumerator) { hRes = pEnumerator->Next(WBEM_INFINITE, 1, &pclsObj, &uReturn); if (0 == uReturn) break; // 获取 Name 属性的值 hRes = pclsObj->Get(_T("Size"), 0, &vtProp, 0, 0); if (V_VT(&vtProp) != VT_NULL) { if (vtProp.llVal < minHardDiskSize) { // Less than 80GB bFound = TRUE; break; } VariantClear(&vtProp); pclsObj->Release(); } } // Cleanup pEnumerator->Release(); pSvc->Release(); pLoc->Release(); CoUninitialize(); } } return bFound; }
2. 使用 WMI 从跟踪中转义
WMI 提供了一种创建新进程和计划任务的方法。沙盒通常使用 CreateProcessInternalW 函数挂钩来跟踪子进程。但是,当您使用 WMI 创建进程时,函数 CreateProcessInternalW 不会在父进程中调用。因此,沙盒可能不会跟踪使用 WMI 创建的进程,并且不会记录其行为。
2.1 使用 WMI 启动进程
可以使用带“Create” 方法的“Win32_Process” 类使用 WMI 创建新进程:
代码语言:javascript复制CoInitializeEx(NULL, COINIT_MULTITHREADED);
// 设置常规 COM 安全级别
hres = CoInitializeSecurity(NULL, -1, NULL, NULL, RPC_C_AUTHN_LEVEL_DEFAULT, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, 0, NULL);
if (FAILED(hres) && hres != RPC_E_TOO_LATE)
break;
// 创建 WbemLocator 的实例
CoCreateInstance(CLSID_WbemLocator, NULL, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (LPVOID*)&wbemLocator);
wbemLocator->ConnectServer(CComBSTR("ROOT\CIMV2"), NULL, NULL, NULL, 0, NULL, NULL, &wbemServices);
// 获取对象Win32_Process
wbemServices->GetObject(CComBSTR("Win32_Process"), 0, NULL, &oWin32Process, &callResult);
wbemServices->GetObject(CComBSTR("Win32_ProcessStartup"), 0, NULL, &oWin32ProcessStartup, &callResult);
oWin32Process->GetMethod(CComBSTR("Create"), 0, &oMethCreate, &oMethCreateSignature);
oMethCreate->SpawnInstance(0, &instWin32Process);
oWin32ProcessStartup->SpawnInstance(0, &instWin32ProcessStartup);
// 设置进程的启动信息
instWin32ProcessStartup->Put(CComBSTR("CreateFlags"), 0, &varCreateFlags, 0);
instWin32Process->Put(CComBSTR("CommandLine"), 0, &varCmdLine, 0);
instWin32Process->Put(CComBSTR("CurrentDirectory"), 0, &varCurDir, 0);
CComVariant varStartupInfo(instWin32ProcessStartup);
instWin32Process->Put(CComBSTR("ProcessStartupInformation"), 0, &varStartupInfo, 0);
wbemServices->ExecMethod(CComBSTR("Win32_Process"), CComBSTR("Create"), 0, NULL, instWin32Process, &pOutParams, &callResult);
2.2 通过 WMI 使用任务计划程序启动进程 (Windows 7)
该技术与“时间”中“使用任务计划程序延迟执行”一节中所述的基本上相同。WMI 只是提供了另一种计划任务的方法。
可以使用带有“Create”方法的“Win32_ScheduledJob”类使用 WMI 创建新任务。
但是,“Win32_ScheduledJob” WMI 类旨在与 AT 命令一起使用,该命令自 Windows 8 起已弃用。
在 Windows 8 及更高版本中,仅当注册表项“HKEY_LOCAL_MACHINESOFTWAREMicrosoftWindows NTCurrentVersionScheduleConfiguration”具有类型为 REG_DWORD 的值“EnableAt”=“1”时,才能使用 WMI 创建计划作业。因此,这种技术不太可能在野外找到。
代码语言:javascript复制strComputer = "."
Set objWMIService = GetObject("winmgmts:" & "{impersonationLevel=Impersonate}!\" & strComputer & "rootcimv2")
Set objSWbemDateTime = CreateObject("WbemScripting.SWbemDateTime")
objSWbemDateTime.SetVarDate(DateAdd("n", 1, Now()))
Set objNewJob = objWMIService.Get("Win32_ScheduledJob")
errJobCreate = objNewJob.Create("malware.exe", objSWbemDateTime.Value, False, , , True, "MaliciousJob")
3.检查上次启动时间
如果在从快照还原 VM 后立即查询上次启动时间,则 WMI 数据库可能包含创建 VM 快照时保存的值。如果快照是在一年前创建的,则即使沙盒更新了上次启动时间,计算出的系统正常运行时间也将是一年。
此事实可用于检测从快照还原的虚拟机。此外,上次启动时间中的任何异常都可以用作沙盒指示器:
- 系统正常运行时间过长(数月甚至数年)
- 系统正常运行时间很短(不到几分钟)
- 使用其他方法获取的上次启动时间与使用 WMI 获取的上次启动时间不同
strComputer = "."
Set objWMIService = GetObject("winmgmts:" & "{impersonationLevel=impersonate}!\" & strComputer & "rootcimv2")
Set colOperatingSystems = objWMIService.ExecQuery ("Select * from Win32_OperatingSystem")
For Each objOS in colOperatingSystems
dtmBootup = objOS.LastBootUpTime
dtmLastBootUpTime = WMIDateStringToDate(dtmBootup)
dtmSystemUptime = DateDiff("n", dtmLastBootUpTime, Now)
Wscript.Echo "System uptime minutes: " & dtmSystemUptime
Next
Function WMIDateStringToDate(dtm)
WMIDateStringToDate = CDate(Mid(dtm, 5, 2) & "/" & _
Mid(dtm, 7, 2) & "/" & Left(dtm, 4) & " " & Mid (dtm, 9, 2) & ":" & _
Mid(dtm, 11, 2) & ":" & Mid(dtm, 13, 2))
End Function
4.检查网络适配器上次重置时间
我们需要检查是否有任何适配器是很久以前最后一次重置的。这可能表示应用程序正在从快照还原的虚拟机中运行:
代码语言:javascript复制strComputer = "."
Set objWMIService = GetObject("winmgmts:" & "{impersonationLevel=impersonate}!\" & strComputer & "rootcimv2")
Set colOperatingSystems = objWMIService.ExecQuery ("Select * from Win32_NetworkAdapter")
For Each objOS in colNetworkAdapters
dtmLastReset = objOS.TimeOfLastReset
dtmLastResetTime = WMIDateStringToDate(dtmLastReset) 'WMIDateStringToDate function from the previous example
dtmAdapterUptime = DateDiff("n", dtmLastResetTime, Now)
Wscript.Echo "Adapter uptime minutes: " & dtmAdapterUptime
Next
锦鲤安全
一个安全技术学习与工具分享平台
点分享
点收藏
点点赞
点在看