JavaWeb分步业务流程审计

  • A+

常见业务场景

  常见的业务模块有:

  • 注册验证模块
  • 重置密码模块
  • 手势密码模块
  • 修改模块(修改密保手机号等)
  • ……

审计要点

  以重置密码流程为例。 常见业务流程如下:

  1. 用户输入需要找回密码的信息,一般是账号、邮箱、用户名等。
  2. 然后就是进行相关的凭证校验了,常见的方式有:
  3. 向用户发送短信验证码,填写验证码校验成功后进入密码重置页面。
  4. 向用户发送重置密码链接(例如通过邮箱),用户点击链接后进入密码重置页面。
  5. 进入密码重置页面后,填写新密码完成重置操作。

  一般的业务流程都会跟凭证校验相关(例如校验短信验证码重置密码等),也就是说基本上是围绕这个凭证进行相关的审计。例如:

  • 存储方式(例如一般将对应的信息保存的session容器中(需要重置、注册的用户、对应的需要对比校验的用户凭证(token、验证码等)))
  • 生命周期
  • 是否可伪造凭证(时间戳、MD5、数字......)
  • 校验方式
  • 是否前端校验
  • 是否绑定用户身份
  • 校验过程是否可伪造
  • ......

常见漏洞场景及审计方法

修改response绕过身份校验(业务流程跨越)

  实际开发场景中会使用到很多ajax相关的技术,目的是让JavaScript发送Http请求,与后台进行交互,获取数据和信息,在不重新加载整个页面的情况下,达到更新部分网页的需求。常见的功能模块诸如验证码的刷新等。但是,一些关键业务逻辑通过JS脚本控制,而非服务端,会导致修改Response绕过身份验证的问题。(本质上其实是直接接口调用。)

  例如常见的修改密码操作,大致流程如下:

图片.png

  修改密码流程分两步进行,第一步校验旧密码,校验成功后第二步提交新密码进行密码修改业务。查看前端JavaScript代码,进行修改密码操作时,首先通过ajax请求调用lpwdValidate接口进行原密码的校验,如果response返回success时即可跳转到modifyPwd.html页面进行修改密码逻辑。

```javascript
if ($('#loginPwd_form').valid() && !$('#loginPasswdError').text().length) {
$.ajax({
type: 'POST',
url: '${contextPath}/system/account/set/lpwdValidate.html',
dataType: 'JSON',
data: {token: hex_sha256($.trim($('#loginPwd').val()))},
success: function(data, status, req) {
if (data.success) {
$('#loginPasswdError').html('');
$('#loginPasswdErrorId').slideUp();

                        $('#oldPasswd').val(hex_sha256($.trim($('#loginPwd').val())));
                        $('#newPasswd').val(hex_sha256($.trim($('#newPwd').val())));
                        $('#repeatPasswd').val(hex_sha256($.trim($('#repeatPwd').val())));
                        $('#loginPwd_form').submit();
                    } else {
                        $('#loginPasswdErrorId').slideDown();
                        $('#loginPasswdError').css('color', 'red');
                        $('#loginPasswdError').html(data.msg);
                        if (data.times <= 0) {
                            $.ajax({
                                type: 'POST',
                                url: '${contextPath}/system/modifyPwd.html',
                                success: function(data, status, req) {
                                }
                            });
                        }
                    }
                }
            });
        }

```

  也就是说通过修改response可以直接跳转到对应的修改密码的页面。那么这时候就需要检查第二步修改密码的接口,查看其是否判断了该步骤的请求是否由上一步骤的业务所发起的。

  直接查看第二步修改密码的接口,可以看到这里仅仅虽然获取了旧密码,但是仅仅是校验用户设定的新密码是否与旧密码一致,防止设置重复的密码,并没有进行原密码是否正确的校验逻辑。同时也没有从相关容器获取上一步校验成功的凭证进行检查(未判断该步骤的请求是否由上一步骤的业务所发起的)。也就是说,可以直接在不校验原密码的情况下,进行流程跨越,直接修改当前用户的密码:

java
@RequestMapping("modifyPwd")
public String setLpwd(Model model, @Valid @ModelAttribute(value="obj") EntPasswdPojo pojo,
BindingResult error,
HttpServletRequest request) {
String lpwd = pojo.getNewPasswd();
String oldLpwd = pojo.getOldPasswd();
String rLpwd = pojo.getRepeatPasswd();
if (lpwd.equals(oldLpwd) || !pojo.getNewPasswd().equals(rLpwd)) {
error.reject(I18nMessageKey.Account.PASSWD_MATCH_ERROR);
return PAGE_URL_PREFIX + "account_set_security_lpwd";
}
String userId = getCurrWebUser(request).getUserId();
accountAppService.doSetLpwd(userId, lpwd);
try {
WebLogUtil.log(request, WebLog.SECURITY_SET, WebLog.UPDATE, "修改登录密码");
} catch (PortalCheckedException e) {
request.setAttribute("errorFlag", false);
log.error(e.getLocalizedMessage());
}
setMenuCode(model,request);
return "success";
}

  相关的页面跳转和接口调用可以通过前端进行操作,但是需要在业务接口中判断该步骤的请求是否由上一步骤的业务所发起的,如果不是则返回提示或者页面失效。可以结合相关的会话容器(例如session)或者cache进行实现,例如一个有n个步骤的业务流程,每一个步骤对应自身的1、2、3、4、....、n,在完成对应的流程后,在对应的会话容器(例如session)或者cache中存入自身业务顺序的数字,在下一个接口业务调度前,首先获取容器中的数字,如果等于n-1(n为当前接口的业务顺序),说明上一步业务已经完成,可以进行进一步的业务操作,防止修改response绕过身份校验(业务流程跨越)的安全缺陷。

弱Token设计缺陷

  相关敏感Token信息不能使用时间戳或者用户账号、邮箱和较短有规律的数字字符等组合加密生成,应当使用复杂无规律的生成机制(类似uuid)让攻击者无法推测出具体的值。否则攻击者可以通过该缺陷构造对应的身份凭证完成对应的业务。

  常见的弱token生成方式:

  • Md5(用户id、邮箱+时间戳)
  • Base(Md5(用户id、邮箱+时间戳))
  • Base64(Md5(用户id、邮箱))
  • ……

  例如下面的例子:生成重置密码的相关链接,然后通过邮件进行传递。当用户点击邮箱中的链接后,凭证校验通过则完成重置密码业务。

  组成关键凭证token的元素为email,时间戳以及对应的activeId(签名),其中activeId(签名)的生成为email+时间戳拼接后进行MD5加密。最后将组合后的token进行base64编码。

```java
@Override
@Transactional
public void saveEmailContent(String email, String url, String operType, String lang, String companyName,String oldEmail) {
MpEmailContentDto emailContent = new MpEmailContentDto();
emailContent.setAddressee(email);
emailContent.setStatus(EmailStatus.NEW.getValue());
emailContent.setType(EmailTypeEnum.HTML.getValue());
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
emailContent.setUid(uuid);
Date date = new Date();
String activeId = MD5Utils.MD5(email + date.getTime());
String token = operType + "&" + email + "&" + date.getTime() + "&" + activeId + "&" + oldEmail ;
String base64Str = Base64.encodeBase64URLSafeString(token.getBytes());
String jumpUrl = url + "?token=" + base64Str;
emailContent.setCreatedTime(date);
emailContent.setFailureCount(0);

CustActiveInfoDto activeInfo = new CustActiveInfoDto();
activeInfo.setId(activeId);
activeInfo.setEmail(email);
activeInfo.setCreatedTime(date);
......

//发送邮件
}
```

  这里的关键凭证生成规则的比较简单,攻击者可以构造对应的身份凭证来达到任意用户密码重置的效果。

  生成的业务凭证需要足够随机,无法被猜解,可以采用uuid代替时间戳:

java
return UUID.randomUUID().toString().replace("-", "").toLowerCase();

回退测试(相关凭证可复用)

  一般情况下,对于分步的业务,会有很多状态性的设计,例如身份校验成功后,会在对应的容器(一般是session)中写入对应的状态(例如flag=success),这是为了在下一步逻辑中对应的接口可以访问当前身份校验的状态,然后根据这个状态判断是否满足完成对应的业务的条件(例如flag为success时完成对应的重置密码操作,否则认为越权操作)。但是在完成业务流程后未及时将对应的状态清除并失效,将会导致回退问题。

  有点类似于验证码可复用的问题,只是一般图形验证码复用会导致暴力破解,这里的话有可能结合CSRF等漏洞进行相关的敏感业务操作。

  例如下面的例子:

  一个简单的重置密码流程,当短信验证码验证成功后,会在session中存入校验成功的字段status为success,然后在下一步接口进行修改密码时,首先校验status的状态,如果不为success则认为流程跨越,未通过身份验证,拒绝业务请求,否则完成对应的业务流程。

  大概流程如下:

图片.png

  相关代码如下:

java
@RequestMapping({"/checkVertifyCode"})
public String checkVertifyCode(String phone,HttpSession session,String vertifyCode,ModelView model){
//......
String code = (String)session.getAttribute("code");
String mobile = (String)session.getAttribute("phone");
//部分安全检查,例如非空检验等
//......
if(!mobile.equals(phone)){
model.addAttribute("msg","短信验证码与手机号不匹配");
return "check";
}else{
//验证码次数检查,防止暴力破解
//......
if(!code.equlas(vertifyCode)){
model.addAttribute("msg","短信验证码错误");
return "check";
}else{
session.setAttribute("phone",mobile);
session.setAttribute("status","success");
}
}
//......
return "resetPwd";
}
@RequestMapping({"/resetPwd"})
public String resetPwd(String pwd,HttpSession session,ModelView model){
String status = (String) session.getAttribute("status");
//......
if(status=null&&status.isEmpty()){
model.addAttribute("msg","请先进行短信验证码身份验证");
return "check";
}else if(status.equals("success")){
this.userServie.updatePwd((String)session.getAttribute("phone"),pwd);
//......
model.addAttribute("msg","重置密码成功,请重新登录");
return "login";
}else{
model.addAttribute("msg","重置密码失败");
//......
return "resetPwd";
}
}

  由于在最后完成重置密码业务后,没有及时将session中的状态校验字段status置回为fail或者NULL,让其清除失效,导致了在session有效期内可以重复地绕过短信校验进行修改密码业务。

  正确的做法是,在相关身份校验凭证校验成功后,对应容器(session,cache)中的相关凭证应该在完成业务流程后及时清除失效。

关键身份凭证绑定问题

  在进行相关业务操作时,没有验证当前身份校验时用的凭证是否属于当前申请使用该项业务的用户。最常见的例如万能短信验证码,利用该缺陷利用自己的凭证完成其他用户的业务操作。

  常见的凭证有:

  • 验证码凭证

例如重置密码需要手机验证码进行身份校验,一般需要用户接受凭证并填写与服务端进行交互。

  • 服务器端返回的Token凭证

一般通过response返回,然后在页面通过hidden属性进行预埋,在进行业务操作时连带业务参数一同发送与服务端进行交互,一般在注册业务里比较多,例如保险、开卡等金融类业务。

  例如下面的例子,常见的注册流程:

图片.png

  具体代码实现:

```java
String phoneNumber = request.getParameter("phoneNumber");
String code = request.getParameter("code");
if(phoneNumber==null||phoneNumber.trim().equals("")||code==null||code.trim().equals("")) {
model.addAttribute("msg","手机号或者验证码为空");
return "register";
}
if(!Pattern.matches("^1[3|5|8]d{9}$", phoneNumber)) {
model.addAttribute("msg",手机号码格式有误!");
return "register";
}
//从session中拿出数据,拿出后就清空session里的数据
HttpSession session =request.getSession();
String code_Session = (String)session.getAttribute("code");

session.removeAttribute("number");

if(code_Session ==null||code_Session.trim().equals("")) {
model.addAttribute("msg",验证码为空!");
return "register";
}
//验证码有效期为10分钟
......
//验证码正确性判断
if(code_Session.trim().equalsIgnoreCase(code)) {
this.UserService.addUser(user);
}else{
model.addAttribute("msg",验证码错误!");
return "register";
}
```

  整个流程只是判断用户输入的验证码以及session中生成的验证码是否一致,若一致则进行对应的业务操作,并没有校验验证码的来源手机号是否与需进行业务操作的手机号是否一致,所以也就是说只要验证码对了,就可以利用这个万能验证码进行任意用户注册了(使用A用户的短信验证码完成B用户的注册申请)。

  正确的做法是,后端进行业务身份校验时,需要校验进行业务操作的手机号是否与接受短信验证码的手机号一致,不一致则认为非法操作,不予进行接下来的业务流程。(关键身份凭证需要校验对应的来源身份信息绑定)

java
//发送验证码的手机号和登录时提交的手机号码不能不一致
String phoneNumber = session.getAttribute("phoneNumber");
if(!phone.trim().equalsIgnoreCase(phoneNumber)){
model.addAttribute("msg","<script>window.alert('注册的手机号与接收短信的手机号不一致');</script>");
return "register";
}

session覆盖

  session覆盖类似于账号参数的修改,只是以控制当前session的方法篡改了需要进行业务操作的账号,结合相关的设计缺陷,导致了越权注册、任意用户重置密码等安全问题。

  例如下面的例子,以重置密码业务为例,大致流程如下:

图片.png

  第一步,首先用户输入需要重置密码的账户,前端进行加密后传递data参数到服务器端,该参数解密之后为loginName,然后服务端将loginName放入session容器中:

java
@RequestMapping(value={"/getSecPwdQuestion"}, method={org.springframework.web.bind.annotation.RequestMethod.POST})
@ResponseBody
public Object getSecPwdQuestion(HttpServletRequest request, HttpServletResponse response,HttpSession session) throws Exception
{
String loginName = decryptData(request.getParameter(“data”));
if (StringUtils.isNotBlank(loginName)) {
session.setAttribute("loginName",loginName);
IscUser user = this.portalService.getUserByLoginName(loginName);
if (user != null) {
List servicelist = this.portalService.getUserSecurityByUserId(user.getId());
if (servicelist.isEmpty()) {
return “1”;
}
for (SelfService vo : servicelist) {
vo.setAnswerValue(null);
vo.setUserId(null);
}
Map map = new HashMap();
map.put(“data”, servicelist);
map.put(“userId”, user.getId());
return map;
}
return “0”;
}
return “0”;
}

  然后进行第二步,验证密保问题,首先从参数中通过后端解密获取用户的loginName,接下来查看缓存中是否有该用户的重置密码请求信息,如果有则对该用户的提交的密保问题答案answer进行验证,验证成功后则生成一个代表密保验证成功的code,将这个code存进session容器中,用于后续步骤验证该用户是否有重置密码的资格。

java
@RequestMapping(value={"/validateUserSecurity"}, method={org.springframework.web.bind.annotation.RequestMethod.POST}
@ResponseBody
public Object validateUserSecurity(HttpServletRequest request, HttpServletResponse response,HttpSession session) throws Exception
{
RestServerResult result = new RestServerResult();
String loginName = decryptData(request.getParameter("data"));
String checkCode = (String)session.getAttribute("loginName");
if ((StringUtils.isNotBlank(loginName)) && (StringUtils.isNotBlank(checkCode))) {
if (loginName.equals(checkCode)) {
IscUser user = this.portalService.getUserByLoginName(loginName);
if (user != null) {
//验证密保问题
String datas = request.getParameter("answer");
Boolean flag = this.portalService.validateUserSecurity(user.getId(), datas);
if (flag.booleanValue()) {
result.setSuccess(true);
String code = “VALIDATION_OF_PASSWORD_PROTECTION_” + DataUtil.generateRandom();
session.setAttribute("code", code);
} else {
result.setSuccess(false);
result.setMessage("验证密码失败");
}
}else {
result.setSuccess(false);
result.setMessage("不存在该用户");
}
} else {
result.setSuccess(false);
result.setMessage("用户不匹配");
}
return result;
}

  接下来进入最后的重置密码的步骤,首先从session中获取code,并没有判断该code与哪个用户进行了绑定。当code合法的时候,从session中获取loginName,然后将前端传入的密码对该loginName所对应的用户进行密码重置:

java
@RequestMapping(value={"/modifyPwd"}, method={org.springframework.web.bind.annotation.RequestMethod.POST})
@ResponseBody
public Object modifyPwd(HttpServletRequest request, HttpServletResponse response,HttpSession session) throws Exception
{
RestServerResult result = new RestServerResult();
String code = session.getAttribute("code");
if (StringUtils.isBlank(auth)){
return new RestServerResult(false, null, “操作无效或操作超时,请重新操作”);
}
String pwd = decryptData(request.getParameter("pwd"));
String loginName = (String)session.getAttribute("loginName");
if ((StringUtils.isNotBlank(loginName)) && (StringUtils.isNotBlank(pwd))) {
if (isValid(code)) {
IscUser vo = this.portalService.getUserByLoginName(loginName);
if (vo != null) {
String ip = getUserIp(request);
String userId = vo.getId();
try {
if ((userId != null) && (pwd != null)){
this.portalService.updateUserPwd(userId, pwd, ip);
}
result.setSuccess(true);
result.setMessage("密码修改成功!");
session.removeAttribute("loginName");
session.removeAttribute("code");
} catch (Exception e) {
this.logger.error("修改密码失败", e);
result.setSuccess(false);
result.setMessage(e.getMessage());
}
} else {
result.setSuccess(false);
result.setMessage("未获取到用户");
}
} else {
result.setSuccess(false);
result.setMessage("密保校验失败,不能继续修改密码!");
}
}else {
result.setSuccess(false);
result.setMessage("修改密码失败!");
}
return result;
}

  可以看到,loginName是在第一步就开始写入到session容器中了,然后后面的流程一直未对其进行修改,并且在最后一步是直接从session中获取,当code合法性检查通过时候,即可完成重置密码操作。那么也就是说,首先使用A账号进行重置密码的业务操作,使用A的密保问题回答正确以后,在/modifyPwd接口调用前,再次调用/getSecPwdQuestion接口,此时传递的账号不再是A账户了,而是账户B。然后再调用/modifyPwd接口,由于此时code是合法的,那么就可以在不知道账户B密保问题的情况下,重置其密码了。

  正确的做法是在相关业务操作请求中要对修改的账号和凭证在最后一步做进一步的一致性校验。避免整个业务流程的完整性遭到破坏。

其他

  另外还需要检查一些开发时为了方便业务测试所留下的“后门”,例如实际万能短信00000、万能token等。

相关推荐: 因为你安全了,所以你危险了——空指针引用

因为你安全了,所以你危险了——空指针引用 ``` 1.本文章属于系列文章《因为你安全了,所以你危险了》中的第一篇 2.本篇文章的作者是Gcow安全团队复眼小组的晏子霜,未经允许禁止转载 3.本篇文章需要你对GDI子系统有一定了解,最好阅读过部分关于Window…