摘要:本文详细介绍了CVE-2022-0847漏洞形成根因,相应补丁修复方法,通过本文让读者对CVE-2022-0847漏洞有更清晰的了解。

本文分享自华为云社区《CVE-2022-0847 DirtyPipe》,作者:安全技术猿。

简介

CVE-2022-0847不需要调用特权syscall就能完成对任意只读文件的修改(有点类似之前的脏牛,但底层原理其实不一样),且由于利用过程中不涉及内存损坏,因此不需要ROP等利用方法,也自然不需要知道内核基址等信息,故不需要对内核版本进行适配(因此可以被广泛利用,危害巨大)。

本质上,这个漏洞是由内存未初始化造成的,且从2016年就存在了,但在当时并不能发生有趣的利用,直到2020年由于对pipe内部实现进行了一些修改,才让这个“BUG”变成了能够利用的“漏洞”。

漏洞分析

这个漏洞主要涉及到两个syscall:

syscall pipe

pipe,我想使用linux的都不陌生它的作用,因此直接从底层实现开始说。

pipe在内核中使用struct pipe_inode_info进行管理,注释中为比较重要的几个字段。

/**
* struct pipe_inode_info - a linux kernel pipe
* @head: The point of buffer production
* @tail: The point of buffer consumption
* @max_usage: The maximum number of slots that may be used in the ring
* @ring_size: total number of buffers (should be a power of 2)
* @tmp_page: cached released page
* @bufs: the circular array of pipe buffers
**/
struct pipe_inode_info {
...
unsigned int head;
unsigned int tail;
unsigned int max_usage;
unsigned int ring_size;
...
struct page *tmp_page;
...
struct pipe_buffer *bufs;
...
};

pipe在内核中使用了环状buffer(bufs字段),而默认的数量为16个(PIPE_DEF_BUFFERS),每一个struct pipe_buffer管理一个buffer,而一个buffer为一页的大小(默认0x1000)。pipe为FIFO的结构体,这可以从head和tail两个字段体现出来,head指向最新生产的buffer,而tail指向开始消费的buffer。

pipe_buffer为如下的结构体,其中这里的page并不直接指向目标页,而是一个物理页的页框(实际使用过程中通过kmap_atomic()获取对应的虚拟地址)。毕竟pipe需要考虑到跨进程,这里在结构体中使用物理页是明知智选。

// >>> include/linux/pipe_fs_i.h:17
/**
* struct pipe_buffer - a linux kernel pipe buffer
* @page: the page containing the data for the pipe buffer
* @offset: offset of data inside the @page
* @len: length of data inside the @page
* @ops: operations associated with this buffer. See @pipe_buf_operations.
* @flags: pipe buffer flags. See above.
* @private: private data owned by the ops.
**/
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

接着我们分析下pipe的使用。假设用户向分配的pipe中写入数据,在内核层就会进入函数pipe_write:

// >>> fs/pipe.c:415
/* 415 */ static ssize_t
/* 416 */ pipe_write(struct kiocb *iocb, struct iov_iter *from)
/* 417 */ {
/* 418 */ struct file *filp = iocb->ki_filp;
// 拿到pipe结构体
/* 419 */ struct pipe_inode_info *pipe = filp->private_data;
/* 420 */ unsigned int head;
/* 421 */ ssize_t ret = 0;
// total_len为此次写入的长度
/* 422 */ size_t total_len = iov_iter_count(from);
/* 423 */ ssize_t chars;
/* 424 */ bool was_empty = false;
/* 425 */ bool wake_next_writer = false;
------
/* 457 */ head = pipe->head;
/* 458 */ was_empty = true;
// 考虑使用merge
/* 459 */ chars = total_len & (PAGE_SIZE-1);
// 如果len&0xFFF !=0 且当前使用的页
/* 460 */ if (chars && !pipe_empty(head, pipe->tail)) {
/* 461 */ unsigned int mask = pipe->ring_size - 1;
/* 462 */ struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask];
/* 463 */ int offset = buf->offset + buf->len;
/* 464 */
/* 465 */ if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && // 可以merge
/* 466 */ offset + chars <= PAGE_SIZE) { // 小于一页
/* 467 */ ret = pipe_buf_confirm(pipe, buf);
------
// 拷贝内容
/* 471 */ ret = copy_page_from_iter(buf->page, offset, chars, from);
------
/* 480 */ }
/* 481 */ }
/* 482 */
// merge失败,或者merge不完全,接着处理剩下的内容
/* 483 */ for (;;) {
------
/* 491 */ head = pipe->head;
// 如果pipe没满
/* 492 */ if (!pipe_full(head, pipe->tail, pipe->max_usage)) {
/* 493 */ unsigned int mask = pipe->ring_size - 1;
// 取当前的pipe buffer
/* 494 */ struct pipe_buffer *buf = &pipe->bufs[head & mask];
/* 495 */ struct page *page = pipe->tmp_page;
/* 496 */ int copied;
/* 497 */ // 如果当前page是空的,就创建新的page
/* 498 */ if (!page) {
/* 499 */ page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT);
------
/* 504 */ pipe->tmp_page = page;
/* 505 */ }
------
/* 519 */ // head++
/* 520 */ pipe->head = head + 1;
/* 521 */ spin_unlock_irq(&pipe->rd_wait.lock);
/* 522 */
/* 523 */ // 开始初始化 pipe buffer 的各个字段
/* 524 */ buf = &pipe->bufs[head & mask];
/* 525 */ buf->page = page;
/* 526 */ buf->ops = &anon_pipe_buf_ops;
/* 527 */ buf->offset = 0;
/* 528 */ buf->len = 0;
/* 529 */ if (is_packetized(filp)) // 一般不走
/* 530 */ buf->flags = PIPE_BUF_FLAG_PACKET;
/* 531 */ else
// 设置flag PIPE_BUF_FLAG_CAN_MERGE
/* 532 */ buf->flags = PIPE_BUF_FLAG_CAN_MERGE;
/* 533 */ pipe->tmp_page = NULL;
/* 534 */
// 复制内容
/* 535 */ copied = copy_page_from_iter(page, 0, PAGE_SIZE, from);
------
/* 541 */ ret += copied;
/* 542 */ buf->offset = 0;
/* 543 */ buf->len = copied;

可以看到,在pipe_write中使用了merge的思想,如果我们分16次向pipe中写入1字节,这16字节不会并不会分别占用16个pipe_buffer,而是连续占用第一个pipe_buffer。这很好理解,不然pipe就堵死了,那利用率就太低了。而负责管理merge的是struct pipe_buffer中的flags字段PIPE_BUF_FLAG_CAN_MERGE。

相对应的,pipe_read也是通过pipe_inode_info拿到pipe_buffer进行读取,这里就不在分析。需要注意的是,pipe_buffer在read过程中只会被修改其offset和len字段,并不会被释放或是修改其flags字段,也就是说PIPE_BUF_FLAG_CAN_MERGE一但设置,则在read/write的过程中就不会再被清除掉。

syscall splice

接着来分析一下splice这个syscall。

splice是在Linux 2.6.16中被引入的(5274f052e7b3dbd81935772eb551dfd0325dfa9d),本质上是为了解决文件对拷的效率问题,它实现了“零拷贝”。

这里稍微展开说说零拷贝。可以思考下在Linux上你会如何实现文件对拷?

最简单的,就是open()两个文件,然后申请一个buffer,然后使用read()/write()来进行拷贝。但这样效率太低,原因是一对read()和write()涉及到4次上下文切换,2次CPU拷贝,2次DMA拷贝。

因此稍微聪明点的人,会使用mmap()+write()的组合,这样涉及4次上下⽂切换,1次 CPU 拷⻉,2次DMA 拷⻉。

更近一步的,会使用sendfile(),调用sendfile()只需提供两个互拷的fd,以及拷贝的长度即可。与 mmap 内存映射⽅式不同的是, sendfile 调⽤中 I/O 数据对⽤户空间是完全不可⻅的。因此它只涉及2次上下⽂切换,2次DMA 拷⻉。

splice()类似,不过使用了pipe机制,从而不需要硬件的支持就能实现两个fd间的零拷贝。它也只涉及2 次上下⽂切换,2次DMA 拷⻉。

一般我们用下面的模式使用splice实现文件对拷:

int in_fd = open(file_to_read);
int out_fd = open(file_to_write);
int anon_pipes[2];
pipe(anon_pipes); while has_content_to_copy:
splice(in_fd,&in_off,anon_pipes[1],NULL,size);
splice(anon_pipes[0],NULL,out_fd,&out_off,size); close(in_fd);
close(out_fd);

可以看到,splice底层用到了pipe。splice支持对接多种设备,例如普通文件,socket等。下面我们啃一下splice的源码,以上面的splice(in_fd,&in_off,anon_pipes[1],NULL,size);为例:

// >>> fs/splice.c:1325
/* 1325 */ SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in,
/* 1326 */ int, fd_out, loff_t __user *, off_out,
/* 1327 */ size_t, len, unsigned int, flags)
/* 1328 */ {
------
// splice是对__do_splice的简单包装
/* 1343 */ error = __do_splice(in.file, off_in, out.file, off_out,
/* 1344 */ len, flags);
------
/* 1350 */ }
// __do_splice 是对 do_splice 的简单包装
// >>> fs/splice.c:1008
/* 1008 */ long do_splice(struct file *in, loff_t *off_in, struct file *out,
/* 1009 */ loff_t *off_out, size_t len, unsigned int flags)
/* 1010 */ {
------
/* 1011 */ struct pipe_inode_info *ipipe;
/* 1012 */ struct pipe_inode_info *opipe;
------
// 从 in/out 中尝试取得 pipe_inode_info
/* 1020 */ ipipe = get_pipe_info(in, true);
/* 1021 */ opipe = get_pipe_info(out, true);
------
// 上面例子中in是普通文件,out是pipe,因此不进这里
/* 1037 */ if (ipipe) {
------
/* 1068 */ }
------
// 进这里
/* 1070 */ if (opipe) {
------
// 调用 do_splice_to
/* 1093 */ ret = do_splice_to(in, &offset, opipe, len, flags);
------
/* 1104 */ }
------
/* 1107 */ }
// >>> fs/splice.c:770
/* 770 */ static long do_splice_to(struct file *in, loff_t *ppos,
/* 771 */ struct pipe_inode_info *pipe, size_t len,
/* 772 */ unsigned int flags)
/* 773 */ {
------
// 这里根据in的f_op->splice_read选择对应的函数
// 由于是普通文件,所以:
//
// >>> fs/read_write.c:28
// /* 28 */ const struct file_operations generic_ro_fops = {
// ------
// /* 32 */ .splice_read = generic_file_splice_read,
// /* 33 */ };
/* 788 */ return in->f_op->splice_read(in, ppos, pipe, len, flags);
/* 789 */ }
// >>> fs/splice.c:298
/* 298 */ ssize_t generic_file_splice_read(struct file *in, loff_t *ppos,
/* 299 */ struct pipe_inode_info *pipe, size_t len,
/* 300 */ unsigned int flags)
/* 301 */ {
/* 302 */ struct iov_iter to;
/* 303 */ struct kiocb kiocb;
/* 304 */ unsigned int i_head;
/* 305 */ int ret;
/* 306 */
// 从pipe中取数据,得到 to
/* 307 */ iov_iter_pipe(&to, READ, pipe, len);
/* 308 */ i_head = to.head;
/* 309 */ init_sync_kiocb(&kiocb, in);
/* 310 */ kiocb.ki_pos = *ppos;
// 进入这里,其实是调用in->f_op->read_iter(&kiocb,&to);
// 即 generic_file_read_iter()
/* 311 */ ret = call_read_iter(in, &kiocb, &to);
------
/* 328 */ }
// 之后:
// generic_file_read_iter()
// -> generic_file_buffered_read()
// -> copy_page_to_iter()
// >>> lib/iov_iter.c:916
/* 916 */ size_t copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
/* 917 */ struct iov_iter *i)
/* 918 */ {
------
/* 921 */ if (i->type & (ITER_BVEC|ITER_KVEC)) {
------
/* 926 */ } else if (unlikely(iov_iter_is_discard(i))) {
------
/* 931 */ } else if (likely(!iov_iter_is_pipe(i)))
/* 932 */ return copy_page_to_iter_iovec(page, offset, bytes, i);
/* 933 */ else
// 这里的i其实就是前面generic_file_splice_read中的to,因此是pipe
/* 934 */ return copy_page_to_iter_pipe(page, offset, bytes, i);
/* 935 */ }
// 终于来到了我们今天的主角:copy_page_to_iter_pipe
// >>> lib/iov_iter.c:375
/* 375 */ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes,
/* 376 */ struct iov_iter *i)
/* 377 */ {
------
/* 378 */ struct pipe_inode_info *pipe = i->pipe;
------
/* 379 */ struct pipe_buffer *buf;
------
/* 394 */ off = i->iov_offset;
------
/* 395 */ buf = &pipe->bufs[i_head & p_mask];
/* 396 */ if (off) {
------
/* 405 */ }
/* 406 */ if (pipe_full(i_head, p_tail, pipe->max_usage))
/* 407 */ return 0;
/* 408 */
// 划重点!!! 没有设置buf->flags
/* 409 */ buf->ops = &page_cache_pipe_buf_ops;
/* 410 */
// page ref_count ++
/* 411 */ get_page(page);
// 直接把普通文件的pipe拿来放到pipe中
/* 412 */ buf->page = page;
/* 413 */ buf->offset = offset;
/* 414 */ buf->len = bytes;
/* 415 */
/* 416 */ pipe->head = i_head + 1;
/* 417 */ i->iov_offset = offset + bytes;
/* 418 */ i->head = i_head;
/* 419 */ out:
/* 420 */ i->count -= bytes;
/* 421 */ return bytes;
/* 422 */ }

可以看到,最主要的逻辑就在copy_page_to_iter_pipe中,之所以splice实现了CPU的零拷贝是因为他直接对目标页的ref count进行了递增,然后把目标页的物理页页框复制到pipe buffer的page处,但这里却忘记设置pipe buffer的flags字段。

OK,现在梳理完了这两个syscall的逻辑,也发现在splice中存在对pipe buffer的flags字段为初始化漏洞,那一种可行的利用思路就出来了。

使用pipe read/write,我们可以让目标pipe的每个pipe buffer都带上PIPE_BUF_FLAG_CAN_MERGEflag。之后打开目标文件,并使用splice 写到之前处理过的pipe中,splice底层会帮助我们把目标文件的page cache 设置到pipe buffer的page字段,但却没有修改flags字段。之后我们再调用pipe write时由于存在PIPE_BUF_FLAG_CAN_MERGEflag字段,内容会接着上次被写入同一个page中,但page其实已经变成了目标文件的page cache,导致直接修改了目标文件page cache。如果之后有其他文件尝试读取这个文件,kernel会优先返回cache中的内容,也就是被我们修改后的page cache。但由于这个修改并不会触发page的dirty属性,因此若由于内存紧张后或系统重启等原因,就会导致这个cache内kernel丢弃,再次读取文件内核就会重新从磁盘中取出未被我们修改的内容(这就是和脏牛的不同点)。

杂谈

这个bug其实在2016年就产生了,但为什么在2020年才能被利用呢?这就涉及到linux代码的历史了。

最早的时候,是否能够merge并不是通过struct pipe_buffer中的flags字段来管理,而是通过struct pipe_buf_operations中的can_merge字段来判断。因此在splice被加入linux时,splice提供了一个新的pipe_buf_operations叫page_cache_pipe_buf_ops,如下:

static struct pipe_buf_operations page_cache_pipe_buf_ops = {
.can_merge = 0,
.map = page_cache_pipe_buf_map,
.unmap = page_cache_pipe_buf_unmap,
.release = page_cache_pipe_buf_release,
};

其中can_merge字段默认就是0,这就解释了为什么在copy_page_to_iter_pipe中不存在对flags的设置逻辑,因为只需要修改fops到page_cache_pipe_buf_ops就可以了。

之后在2016年的一个commit中 commit 241699cd72a8 “new iov_iter flavour: pipe-backed” (Linux 4.9, 2016),添加了两个函数,其中一个就是copy_page_to_iter_pipe,里面对pipe_buffer的flags没有进行初始化,但现在还没出什么大问题,因为此时can_merge参数还在fops中,且flags中也没有什么有趣的选项。

时间来到2019年,Commit 01e7187b4119 “pipe: stop using ->can_merge” (Linux 5.0, 2019)中开始对can_merge字段下手了,但这个时候操刀还比较暴力,除了把所有使用所有fops中的can_merge字段删除外,还增加了一个函数叫pipe_buf_can_merge,可能是发现除了匿名管道外,所有的管道都不支持merge,所以只要判断一下fops是不是anon_pipe_buf_ops就行了。到目前为止,merge操作和16年的未初始化bug还没挂钩。

static bool pipe_buf_can_merge(struct pipe_buffer *buf)
{
return buf->ops == &anon_pipe_buf_ops;
}

终于,在2020年,可能还是感觉这种判断太过于暴力,于是把merge操作的判断塞进了pipe_buffer的flags中:Commit f6dd975583bd “pipe: merge anon_pipe_buf*_ops” (Linux 5.8, 2020) 。16年埋下的bug终于在4年后变成了漏洞。

漏洞修复

https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9d2231c5d74e13b2a0546fee6737ee4446017903

内核的修复方法很简单,把两处pipe buffer的flags未初始化补上即可。

diff --git a/lib/iov_iter.c b/lib/iov_iter.c
index b0e0acdf96c15..6dd5330f7a995 100644
--- a/lib/iov_iter.c
+++ b/lib/iov_iter.c
@@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t by
return 0; buf->ops = &page_cache_pipe_buf_ops;
+ buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
@@ -577,6 +578,7 @@ static size_t push_pipe(struct iov_iter *i, size_t size,
break; buf->ops = &default_pipe_buf_ops;
+ buf->flags = 0;
buf->page = page;
buf->offset = 0;
buf->len = min_t(ssize_t, left, PAGE_SIZE);

阅读福利:

试试下面的漏扫服务,看看系统是否存在安全风险:>>>漏洞扫描服务

参考

点击关注,第一时间了解华为云新鲜技术~

详解CVE-2022-0847 DirtyPipe漏洞的更多相关文章

  1. APP漏洞扫描器之本地拒绝服务检测详解

    APP漏洞扫描器之本地拒绝服务检测详解 阿里聚安全的Android应用漏洞扫描器有一个检测项是本地拒绝服务漏洞的检测,采用的是静态分析加动态模糊测试的方法来检测,检测结果准确全面.本文将讲一下应用漏洞 ...

  2. android WebView详解,常见漏洞详解和安全源码

    这篇博客主要来介绍 WebView 的相关使用方法,常见的几个漏洞,开发中可能遇到的坑和最后解决相应漏洞的源码,以及针对该源码的解析.  转载请注明出处:http://blog.csdn.net/se ...

  3. 第六天实验详解——dedecms通过xss漏洞写马

    第六天实验详解 **XSS跨站攻击的分类** XSS漏洞类型主要分为持久型和非持久型两种: 1. 非持久型XSS漏洞一般存在于URL参数中,需要访问黑客构造好的特定URL才能触发漏洞. 2. 持久型X ...

  4. Java反序列化漏洞详解

      Java反序列化漏洞从爆出到现在快2个月了,已有白帽子实现了jenkins,weblogic,jboss等的代码执行利用工具.本文对于Java反序列化的漏洞简述后,并对于Java反序列化的Poc进 ...

  5. SQL注入漏洞技术的详解

    SQL注入漏洞详解 目录 SQL注入的分类 判断是否存在SQL注入 一:Boolean盲注 二:union 注入 三:文件读写 四:报错注入 floor报错注入 ExtractValue报错注入 Up ...

  6. [转帖]基于VIM漏洞CVE-2019-12735的VIM宏后门病毒详解

    基于VIM漏洞CVE-2019-12735的VIM宏后门病毒详解 不明觉厉 只要是人做的东西 就会有bug 就会有安全问题 就看发现bug 或者是发现安全问题 有没有收益了 会用linux的都是比较熟 ...

  7. 《手把手教你》系列基础篇(八十六)-java+ selenium自动化测试-框架设计基础-Log4j实现日志输出(详解教程)

    1.简介 自动化测试中如何输出日志文件.任何软件,都会涉及到日志输出.所以,在测试人员报bug,特别是崩溃的bug,一般都要提供软件产品的日志文件.开发通过看日志文件,知道这个崩溃产生的原因,至少知道 ...

  8. 架构设计:远程调用服务架构设计及zookeeper技术详解(下篇)

    一.下篇开头的废话 终于开写下篇了,这也是我写远程调用框架的第三篇文章,前两篇都被博客园作为[编辑推荐]的文章,很兴奋哦,嘿嘿~~~~,本人是个很臭美的人,一定得要截图为证: 今天是2014年的第一天 ...

  9. linux yum命令详解

    yum(全称为 Yellow dog Updater, Modified)是一个在Fedora和RedHat以及SUSE中的Shell前端软件包管理器.基於RPM包管理,能够从指定的服务器自动下载RP ...

  10. linux查看端口及端口详解

    今天现场查看了TCP端口的占用情况,如下图   红色部分是IP,现场那边问我是不是我的程序占用了tcp的链接,,我远程登陆现场查看了一下,这种类型的tcp链接占用了400多个,,后边查了一下资料,说E ...

随机推荐

  1. 动态规划 洛谷P1616 疯狂的采药

    动态规划 洛谷P1616 疯狂的采药 同样也是洛谷的动态规划一个普及-的题目,接下来分享一下我做题代码 看到题目,没很认真的看数据大小,我就提交了我的代码: 1 //动态规划 洛谷P1616 疯狂的采 ...

  2. 模拟web服务器http请求应答

    我们在浏览器打开网页,其实是向远端服务器提出页面发送请求,远端服务器在接到请求后,就开始执行请求页面的程序文件,然后将执行结果通过html格式,发送到你的浏览器,再显示出来.以下用百度(www.bai ...

  3. Apollo代码学习(七)—MPC与LQR比较

    前言 Apollo中用到了PID.MPC和LQR三种控制器,其中,MPC和LQR控制器在状态方程的形式.状态变量的形式.目标函数的形式等有诸多相似之处,因此结合自己目前了解到的信息,将两者进行一定的比 ...

  4. 阐述在Yii2上实现跳转提示页

    序言 为了让用户有更加良好的体验,在操作成功或者失败后,来个提示并跳转页面,我就在Yii2上实现了这一个效果.在写这个跳转提示页的时候,找资料我发现网上关于这方面的中文资料真的很少,大家也都共享下吧! ...

  5. w3schools网站的HTML教程之HTML编辑器

    使用记事本或文本编辑器编写 HTML HTML 可以使用如下专业的 HTML 编辑器进行编辑: Microsoft WebMatrix Sublime Text 然而,我们推荐使用记事本(PC)或文本 ...

  6. web页面性能优化之接口前置

    上个Q做了一波web性能优化,积累了一点点经验 记录分享一下. 先分享一个比较常用的接口前置 的优化方案吧 优化前首屏秒开大约在40%左右 首屏秒开大约提高了25% 先发一张优化成果图 前置原因 对于 ...

  7. 前端每日实战:133# 视频演示如何用 CSS 和 GSAP 创作有多个关键帧的连续动画

    效果预览 按下右侧的"点击预览"按钮可以在当前页面预览,点击链接可以全屏预览. https://codepen.io/comehope/pen/eLMKJG 可交互视频 此视频是可 ...

  8. 【Android开发】Android 颜色透明度换算

    透明度 透明度分为256阶(0-255),计算机上用16进制表示为(00-ff). 透明就是0阶,不透明就是255阶,如果50%透明就是127阶(256的一半当然是128,但因为是从0开始,所以实际上 ...

  9. java中请给一个Abstract类实现接口的实例!

    2.Abstract类实现接口 马克-to-win:如果实现某接口的类是abstract类,则它可以不实现该接口所有的方法.但其非abstract的子类中必须拥有所有抽象方法的实在的方法体:(当然它a ...

  10. 给一个非矩形数组(Nonrectangular Arrays)

    Nonrectangular Arrays(非矩形数组)  public class Test {     public static void main(String[] args) {       ...