本文是從開(kāi)源項(xiàng)目 RuoYi 的提交記錄文字描述中根據(jù)關(guān)鍵字漏洞|安全|阻止篩選而來(lái)。旨在為大家介紹日常項(xiàng)目開(kāi)發(fā)中需要注意的一些安全問(wèn)題以及如何解決。
項(xiàng)目安全是每個(gè)開(kāi)發(fā)人員都需要重點(diǎn)關(guān)注的問(wèn)題。如果項(xiàng)目漏洞太多,很容易遭受黑客攻擊與用戶(hù)信息泄露的風(fēng)險(xiǎn)。本文將結(jié)合3個(gè)典型案例,解釋常見(jiàn)的安全漏洞及修復(fù)方案,幫助大家在項(xiàng)目開(kāi)發(fā)中進(jìn)一步提高安全意識(shí)。
- RuoYi項(xiàng)目地址:https://gitee.com/y_project/RuoYi
- 博主github地址:https://github.com/wayn111,歡迎大家關(guān)注
一、重置用戶(hù)密碼
RuoYi 項(xiàng)目中有一個(gè)重置用戶(hù)密碼的接口,在提交記錄 dd37524b
之前的代碼如下:
@Log(title = "重置密碼", businessType = BusinessType.UPDATE)
@PostMapping("/resetPwd")
@ResponseBody
public AjaxResult resetPwd(SysUser user)
{
user.setSalt(ShiroUtils.randomSalt());
user.setPassword(passwordService.encryptPassword(user.getLoginName(),
user.getPassword(), user.getSalt()));
int rows = userService.resetUserPwd(user);
if (rows > 0)
{
setSysUser(userService.selectUserById(user.getUserId()));
return success();
}
return error();
}
可以看出該接口會(huì)讀取傳入的用戶(hù)信息,重置完用戶(hù)密碼后,會(huì)根據(jù)傳入的 userId 更新數(shù)據(jù)庫(kù)以及緩存。
這里有一個(gè)非常嚴(yán)重的安全問(wèn)題就是盲目相信傳入的用戶(hù)信息,如果攻擊人員通過(guò)接口構(gòu)造請(qǐng)求,并且在傳入的 user 參數(shù)中設(shè)置 userId 為其他用戶(hù)的 userId,那么這個(gè)接口就會(huì)導(dǎo)致某些用戶(hù)的密碼被重置因而被攻擊人員掌握。
1.1 攻擊流程
假如攻擊人員掌握了其他用戶(hù)的 userId 以及登錄賬號(hào)名
- 構(gòu)造重置密碼請(qǐng)求
- 將 userId 設(shè)置未其他用戶(hù)的 userId
- 服務(wù)端根據(jù)傳入的 userId 修改用戶(hù)密碼
- 使用新的用戶(hù)賬號(hào)以及重置后的密碼進(jìn)行登錄
- 攻擊成功
1.2 如何解決
在記錄 dd37524b
提交之后,代碼更新如下:
@Log(title = "重置密碼", businessType = BusinessType.UPDATE)
@PostMapping("/resetPwd")
@ResponseBody
public AjaxResult resetPwd(String oldPassword, String newPassword)
{
SysUser user = getSysUser();
if (StringUtils.isNotEmpty(newPassword)
&& passwordService.matches(user, oldPassword))
{
user.setSalt(ShiroUtils.randomSalt());
user.setPassword(passwordService.encryptPassword(
user.getLoginName(), newPassword, user.getSalt()));
if (userService.resetUserPwd(user) > 0)
{
setSysUser(userService.selectUserById(user.getUserId()));
return success();
}
return error();
}
else
{
return error("修改密碼失敗,舊密碼錯(cuò)誤");
}
}
解決方法其實(shí)很簡(jiǎn)單,不要盲目相信用戶(hù)傳入的參數(shù),通過(guò)登錄狀態(tài)獲取當(dāng)前登錄用戶(hù)的userId。如上代碼通過(guò) getSysUser()
方法獲取當(dāng)前登錄用戶(hù)的 userId 后,再根據(jù) userId 重置密碼。
二、文件下載
文件下載作為 web 開(kāi)發(fā)中,每個(gè)項(xiàng)目都會(huì)遇到的功能,相信對(duì)大家而言都不陌生。RuoYi 在提交記錄 18f6366f
之前的下載文件邏輯如下:
@GetMapping("common/download")
public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
{
try
{
if (!FileUtils.isValidFilename(fileName))
{
throw new Exception(StringUtils.format(
"文件名稱(chēng)({})非法,不允許下載。 ", fileName));
}
String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
String filePath = Global.getDownloadPath() + fileName;
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
FileUtils.setAttachmentResponseHeader(response, realFileName);
FileUtils.writeBytes(filePath, response.getOutputStream());
if (delete)
{
FileUtils.deleteFile(filePath);
}
}
catch (Exception e)
{
log.error("下載文件失敗", e);
}
}
public class FileUtils
{
public static String FILENAME_PATTERN =
"[a-zA-Z0-9_\\-\\|\\.\\u4e00-\\u9fa5]+";
public static boolean isValidFilename(String filename)
{
return filename.matches(FILENAME_PATTERN);
}
}
可以看到代碼中在下載文件時(shí),會(huì)判斷文件名稱(chēng)是否合法,如果不合法會(huì)提示 文件名稱(chēng)({})非法,不允許下載。 的字樣。咋一看,好像沒(méi)什么問(wèn)題,博主公司項(xiàng)目中下載文件也有這種類(lèi)似代碼。傳入下載文件名稱(chēng),然后再指定目錄中找到要下載的文件后,通過(guò)流回寫(xiě)給客戶(hù)端。
既然如此,那我們?cè)倏匆幌绿峤挥涗?18f6366f
的描述信息,
不看不知道,一看嚇一跳,原來(lái)再這個(gè)提交之前,項(xiàng)目中存在任意文件下載漏洞,這里博主給大家講解一下為什么會(huì)存在任意文件下載漏洞。
2.1 攻擊流程
假如下載目錄為 /data/upload/
- 構(gòu)造下載文件請(qǐng)求
- 設(shè)置下載文件名稱(chēng)為:
../../home/重要文件.txt
- 服務(wù)端將文件名與下載目錄進(jìn)行拼接,獲取實(shí)際下載文件的完整路徑為
/data/upload/../../home/重要文件.txt
- 由于下載文件包含 .. 字符,會(huì)執(zhí)行上跳目錄的邏輯
- 上跳目錄邏輯執(zhí)行完畢,實(shí)際下載文件為
/home/重要文件.txt
- 攻擊成功
2.2 如何解決
我們看一下提交記錄 18f6366f
主要干了什么,代碼如下:
@GetMapping("common/download")
public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
{
try
{
if (!FileUtils.checkAllowDownload(fileName))
{
throw new Exception(StringUtils.format(
"文件名稱(chēng)({})非法,不允許下載。 ", fileName));
}
String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
String filePath = Global.getDownloadPath() + fileName;
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
FileUtils.setAttachmentResponseHeader(response, realFileName);
FileUtils.writeBytes(filePath, response.getOutputStream());
if (delete)
{
FileUtils.deleteFile(filePath);
}
}
catch (Exception e)
{
log.error("下載文件失敗", e);
}
}
public class FileUtils
{
/**
* 檢查文件是否可下載
*
* @param resource 需要下載的文件
* @return true 正常 false 非法
*/
public static boolean checkAllowDownload(String resource)
{
// 禁止目錄上跳級(jí)別
if (StringUtils.contains(resource, ".."))
{
return false;
}
// 檢查允許下載的文件規(guī)則
if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION,
FileTypeUtils.getFileType(resource)))
{
return true;
}
// 不在允許下載的文件規(guī)則
return false;
}
}
...
public static final String[] DEFAULT_ALLOWED_EXTENSION = {
// 圖片
"bmp", "gif", "jpg", "jpeg", "png",
// word excel powerpoint
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
// 壓縮文件
"rar", "zip", "gz", "bz2",
// 視頻格式
"mp4", "avi", "rmvb",
// pdf
"pdf" };
...
public class FileTypeUtils
{
/**
* 獲取文件類(lèi)型
* <p>
* 例如: ruoyi.txt, 返回: txt
*
* @param fileName 文件名
* @return 后綴(不含".")
*/
public static String getFileType(String fileName)
{
int separatorIndex = fileName.lastIndexOf(".");
if (separatorIndex < 0)
{
return "";
}
return fileName.substring(separatorIndex + 1).toLowerCase();
}
}
可以看到,提交記錄 18f6366f
中,將下載文件時(shí)的 FileUtils.isValidFilename(fileName)
方法換成了 FileUtils.checkAllowDownload(fileName)
方法。這個(gè)方法會(huì)檢查文件名稱(chēng)參數(shù)中是否包含 .. ,以防止目錄上跳,然后再檢查文件名稱(chēng)是否再白名單中。這樣就可以避免任意文件下載漏洞。
路徑遍歷允許攻擊者通過(guò)操縱路徑的可變部分訪(fǎng)問(wèn)目錄和文件的內(nèi)容。在處理文件上傳、下載等操作時(shí),我們需要對(duì)路徑參數(shù)進(jìn)行嚴(yán)格校驗(yàn),防止目錄遍歷漏洞。
三、分頁(yè)查詢(xún)排序參數(shù)
RuoYi 項(xiàng)目作為一個(gè)后臺(tái)管理項(xiàng)目,幾乎每個(gè)菜單都會(huì)用到分頁(yè)查詢(xún),因此項(xiàng)目中封裝了分頁(yè)查詢(xún)類(lèi) PageDomain
,其他會(huì)讀取客戶(hù)端傳入的 orderByColumn
參數(shù)。再提交記錄 807b7231
之前,分頁(yè)查詢(xún)代碼如下:
public class PageDomain
{
...
public void setOrderByColumn(String orderByColumn)
{
this.orderByColumn = orderByColumn;
}
...
}
/**
* 設(shè)置請(qǐng)求分頁(yè)數(shù)據(jù)
*/
public static void startPage()
{
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
String orderBy = pageDomain.getOrderBy();
Boolean reasonable = pageDomain.getReasonable();
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
/**
* 分頁(yè)查詢(xún)
*/
@RequiresPermissions("system:post:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysPost post)
{
startPage();
List<SysPost> list = postService.selectPostList(post);
return getDataTable(list);
}
可以看到,分頁(yè)查詢(xún)一般會(huì)直接條用封裝好的 startPage()
方法,會(huì)將 PageDomain
的 orderByColumn
屬性直接放進(jìn) PageHelper
中,最后也就會(huì)拼接在實(shí)際的 SQL 查詢(xún)語(yǔ)句中。
3.1 攻擊流程
假如攻擊人員知道用戶(hù)表名稱(chēng)為 users,
- 構(gòu)造分頁(yè)查詢(xún)請(qǐng)求
- 傳入
orderByColumn
參數(shù)為1; DROP TABLE users;
- 實(shí)際執(zhí)行的 SQL 可能為:
SELECT * FROM users WHERE username = 'admin' ORDER BY 1; DROP TABLE users;
- 執(zhí)行 SQL,
DROP TABLE users;
完畢,users 表被刪除 - 攻擊成功
3.2 如何解決
再提交記錄 807b7231
之后,針對(duì)排序參數(shù)做了轉(zhuǎn)義處理,最新代碼如下,
public class PageDomain
{
...
public void setOrderByColumn(String orderByColumn)
{
this.orderByColumn = SqlUtil.escapeSql(orderByColumn);
}
}
/**
* sql操作工具類(lèi)
*
* @author ruoyi
*/
public class SqlUtil
{
/**
* 僅支持字母、數(shù)字、下劃線(xiàn)、空格、逗號(hào)、小數(shù)點(diǎn)(支持多個(gè)字段排序)
*/
public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";
/**
* 檢查字符,防止注入繞過(guò)
*/
public static String escapeOrderBySql(String value)
{
if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value))
{
throw new UtilException("參數(shù)不符合規(guī)范,不能進(jìn)行查詢(xún)");
}
return value;
}
/**
* 驗(yàn)證 order by 語(yǔ)法是否符合規(guī)范
*/
public static boolean isValidOrderBySql(String value)
{
return value.matches(SQL_PATTERN);
}
...
}
可以看到對(duì)于 order by
語(yǔ)句后可以拼接的字符串做了正則匹配,僅支持字母、數(shù)字、下劃線(xiàn)、空格、逗號(hào)、小數(shù)點(diǎn)(支持多個(gè)字段排序)。以此可以避免 order by
后面拼接其他非法字符,例如 drop|if()|union
等等,因而可以避免 order by
注入問(wèn)題。
SQL 注入是 Web 應(yīng)用中最常見(jiàn)也是最嚴(yán)重的漏洞之一。它允許攻擊者通過(guò)將SQL命令插入到 Web 表單提交中實(shí)現(xiàn),數(shù)據(jù)庫(kù)中執(zhí)行非法 SQL 命令。
永遠(yuǎn)不要信任用戶(hù)的輸入,特別是在拼接SQL語(yǔ)句時(shí)。我們應(yīng)該對(duì)用戶(hù)傳入的不可控參數(shù)進(jìn)行過(guò)濾。
四、總結(jié)
通過(guò)這三個(gè) RuoYi 項(xiàng)目中的代碼案例,我們可以總結(jié)出項(xiàng)目開(kāi)發(fā)中需要注意的幾點(diǎn):
- 不要盲目相信用戶(hù)傳入的參數(shù)。無(wú)論是修改密碼還是文件下載,都不應(yīng)該直接使用用戶(hù)傳入的參數(shù)構(gòu)造 SQL 語(yǔ)句或拼接路徑,這會(huì)導(dǎo)致 SQL 注入及路徑遍歷等安全漏洞。我們應(yīng)該根據(jù)實(shí)際業(yè)務(wù)獲取真實(shí)的用戶(hù) ID 或其他參數(shù),然后再進(jìn)行操作。
- SQL 參數(shù)要進(jìn)行轉(zhuǎn)義。在拼接 SQL 語(yǔ)句時(shí),對(duì)用戶(hù)傳入的不可控參數(shù)一定要進(jìn)行轉(zhuǎn)義,防止 SQL 注入。
- 路徑要進(jìn)行校驗(yàn)。在處理文件上傳下載等操作時(shí),對(duì)路徑參數(shù)要進(jìn)行校驗(yàn),防止目錄遍歷漏洞。例如判斷路徑中是否包含 .. 字符。
- 接口要設(shè)置權(quán)限。對(duì)一些敏感接口,例如重置密碼,我們需要設(shè)置對(duì)應(yīng)的權(quán)限,避免用戶(hù)越權(quán)訪(fǎng)問(wèn)。
- 記錄提交信息。在記錄提交信息時(shí),最好詳細(xì)描述本次提交的內(nèi)容,例如修復(fù)的漏洞或新增的功能。這在后續(xù)代碼審計(jì)或回顧項(xiàng)目提交歷史時(shí)會(huì)很有幫助。
- 定期代碼審計(jì)。作為項(xiàng)目維護(hù)人員,我們需要定期進(jìn)行代碼審計(jì),找出項(xiàng)目中可能存在的漏洞,并及時(shí)修復(fù)。這可以最大限度地保證項(xiàng)目代碼的安全性與健壯性。
綜上,寫(xiě)代碼不僅僅是完成需求這么簡(jiǎn)單。我們還需要在各個(gè)細(xì)節(jié)上多加注意,對(duì)用戶(hù)傳入的參數(shù)要保持警惕,對(duì) SQL 語(yǔ)句要謹(jǐn)慎拼接,對(duì)路徑要嚴(yán)謹(jǐn)校驗(yàn)。定期代碼審計(jì)可以盡早發(fā)現(xiàn)并修復(fù)項(xiàng)目漏洞,給用戶(hù)更安全可靠的產(chǎn)品。希望通過(guò)這幾個(gè)案例,可以提醒大家在代碼編寫(xiě)過(guò)程中進(jìn)一步加強(qiáng)安全意識(shí)。
到此本文講解完畢,感謝大家閱讀,感興趣的朋友可以點(diǎn)贊加關(guān)注,你的支持將是我的更新動(dòng)力??。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-417816.html
公眾號(hào)【waynblog】每周更新博主最新技術(shù)文章,歡迎大家關(guān)注文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-417816.html
到了這里,關(guān)于項(xiàng)目講解之常見(jiàn)安全漏洞的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!