專案講解之常見安全漏洞

2023-04-19 06:01:04

本文是從開源專案 RuoYi 的提交記錄文字描述中根據關鍵字漏洞|安全|阻止篩選而來。旨在為大家介紹日常專案開發中需要注意的一些安全問題以及如何解決。

專案安全是每個開發人員都需要重點關注的問題。如果專案漏洞太多,很容易遭受駭客攻擊與使用者資訊洩露的風險。本文將結合3個典型案例,解釋常見的安全漏洞及修復方案,幫助大家在專案開發中進一步提高安全意識。

一、重置使用者密碼

RuoYi 專案中有一個重置使用者密碼的介面,在提交記錄 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();
}

可以看出該介面會讀取傳入的使用者資訊,重置完使用者密碼後,會根據傳入的 userId 更新資料庫以及快取。

這裡有一個非常嚴重的安全問題就是盲目相信傳入的使用者資訊,如果攻擊人員通過介面構造請求,並且在傳入的 user 引數中設定 userId 為其他使用者的 userId,那麼這個介面就會導致某些使用者的密碼被重置因而被攻擊人員掌握。

1.1 攻擊流程

假如攻擊人員掌握了其他使用者的 userId 以及登入賬號名

  1. 構造重置密碼請求
  2. 將 userId 設定未其他使用者的 userId
  3. 伺服器端根據傳入的 userId 修改使用者密碼
  4. 使用新的使用者賬號以及重置後的密碼進行登入
  5. 攻擊成功

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("修改密碼失敗,舊密碼錯誤");
    }
}

解決方法其實很簡單,不要盲目相信使用者傳入的引數,通過登入狀態獲取當前登入使用者的userId。如上程式碼通過 getSysUser() 方法獲取當前登入使用者的 userId 後,再根據 userId 重置密碼。

二、檔案下載

檔案下載作為 web 開發中,每個專案都會遇到的功能,相信對大家而言都不陌生。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(
                      "檔名稱({})非法,不允許下載。 ", 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);
    }
}

可以看到程式碼中在下載檔案時,會判斷檔名稱是否合法,如果不合法會提示 檔名稱({})非法,不允許下載。 的字樣。咋一看,好像沒什麼問題,博主公司專案中下載檔案也有這種類似程式碼。傳入下載檔名稱,然後再指定目錄中找到要下載的檔案後,通過流回寫給使用者端。

既然如此,那我們再看一下提交記錄 18f6366f 的描述資訊,

不看不知道,一看嚇一跳,原來再這個提交之前,專案中存在任意檔案下載漏洞,這裡博主給大家講解一下為什麼會存在任意檔案下載漏洞。

2.1 攻擊流程

假如下載目錄為 /data/upload/

  1. 構造下載檔案請求
  2. 設定下載檔名稱為:../../home/重要檔案.txt
  3. 伺服器端將檔名與下載目錄進行拼接,獲取實際下載檔案的完整路徑為 /data/upload/../../home/重要檔案.txt
  4. 由於下載檔案包含 .. 字元,會執行上跳目錄的邏輯
  5. 上跳目錄邏輯執行完畢,實際下載檔案為 /home/重要檔案.txt
  6. 攻擊成功

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(
                      "檔名稱({})非法,不允許下載。 ", 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)
    {
        // 禁止目錄上跳級別
        if (StringUtils.contains(resource, ".."))
        {
            return false;
        }

        // 檢查允許下載的檔案規則
        if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION,
                            FileTypeUtils.getFileType(resource)))
        {
            return true;
        }

        // 不在允許下載的檔案規則
        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
{
    /**
     * 獲取檔案型別
     * <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 中,將下載檔案時的 FileUtils.isValidFilename(fileName) 方法換成了 FileUtils.checkAllowDownload(fileName) 方法。這個方法會檢查檔名稱引數中是否包含 .. ,以防止目錄上跳,然後再檢查檔名稱是否再白名單中。這樣就可以避免任意檔案下載漏洞。

路徑遍歷允許攻擊者通過操縱路徑的可變部分存取目錄和檔案的內容。在處理檔案上傳、下載等操作時,我們需要對路徑引數進行嚴格校驗,防止目錄遍歷漏洞。

三、分頁查詢排序引數

RuoYi 專案作為一個後臺管理專案,幾乎每個選單都會用到分頁查詢,因此專案中封裝了分頁查詢類 PageDomain,其他會讀取使用者端傳入的 orderByColumn 引數。再提交記錄 807b7231 之前,分頁查詢程式碼如下:

public class PageDomain
{
    ...
    public void setOrderByColumn(String orderByColumn)
    {
        this.orderByColumn = orderByColumn;
    }
    ...
}

/**
 * 設定請求分頁資料
 */
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);
}

/**
 * 分頁查詢
 */
@RequiresPermissions("system:post:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysPost post)
{
    startPage();
    List<SysPost> list = postService.selectPostList(post);
    return getDataTable(list);
}

可以看到,分頁查詢一般會直接條用封裝好的 startPage() 方法,會將 PageDomainorderByColumn 屬性直接放進 PageHelper 中,最後也就會拼接在實際的 SQL 查詢語句中。

3.1 攻擊流程

假如攻擊人員知道使用者表名稱為 users,

  1. 構造分頁查詢請求
  2. 傳入 orderByColumn 引數為 1; DROP TABLE users;
  3. 實際執行的 SQL 可能為:SELECT * FROM users WHERE username = 'admin' ORDER BY 1; DROP TABLE users;
  4. 執行 SQL,DROP TABLE users; 完畢,users 表被刪除
  5. 攻擊成功

3.2 如何解決

再提交記錄 807b7231 之後,針對排序引數做了跳脫處理,最新程式碼如下,

public class PageDomain
{
    ...
    public void setOrderByColumn(String orderByColumn)
    {
        this.orderByColumn = SqlUtil.escapeSql(orderByColumn);
    }
}

/**
 * sql操作工具類
 * 
 * @author ruoyi
 */
public class SqlUtil
{
    /**
     * 僅支援字母、數位、下劃線、空格、逗號、小數點(支援多個欄位排序)
     */
    public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

    /**
     * 檢查字元,防止注入繞過
     */
    public static String escapeOrderBySql(String value)
    {
        if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value))
        {
            throw new UtilException("引數不符合規範,不能進行查詢");
        }
        return value;
    }

    /**
     * 驗證 order by 語法是否符合規範
     */
    public static boolean isValidOrderBySql(String value)
    {
        return value.matches(SQL_PATTERN);
    }
    ...
}

可以看到對於 order by 語句後可以拼接的字串做了正則匹配,僅支援字母、數位、下劃線、空格、逗號、小數點(支援多個欄位排序)。以此可以避免 order by 後面拼接其他非法字元,例如 drop|if()|union 等等,因而可以避免 order by 注入問題。

SQL 注入是 Web 應用中最常見也是最嚴重的漏洞之一。它允許攻擊者通過將SQL命令插入到 Web 表單提交中實現,資料庫中執行非法 SQL 命令。
永遠不要信任使用者的輸入,特別是在拼接SQL語句時。我們應該對使用者傳入的不可控引數進行過濾。

四、總結

通過這三個 RuoYi 專案中的程式碼案例,我們可以總結出專案開發中需要注意的幾點:

  1. 不要盲目相信使用者傳入的引數。無論是修改密碼還是檔案下載,都不應該直接使用使用者傳入的引數構造 SQL 語句或拼接路徑,這會導致 SQL 注入及路徑遍歷等安全漏洞。我們應該根據實際業務獲取真實的使用者 ID 或其他引數,然後再進行操作。
  2. SQL 引數要進行跳脫。在拼接 SQL 語句時,對使用者傳入的不可控引數一定要進行跳脫,防止 SQL 注入。
  3. 路徑要進行校驗。在處理檔案上傳下載等操作時,對路徑引數要進行校驗,防止目錄遍歷漏洞。例如判斷路徑中是否包含 .. 字元。
  4. 介面要設定許可權。對一些敏感介面,例如重置密碼,我們需要設定對應的許可權,避免使用者越權存取。
  5. 記錄提交資訊。在記錄提交資訊時,最好詳細描述本次提交的內容,例如修復的漏洞或新增的功能。這在後續程式碼審計或回顧專案提交歷史時會很有幫助。
  6. 定期程式碼審計。作為專案維護人員,我們需要定期進行程式碼審計,找出專案中可能存在的漏洞,並及時修復。這可以最大限度地保證專案程式碼的安全性與健壯性。

綜上,寫程式碼不僅僅是完成需求這麼簡單。我們還需要在各個細節上多加註意,對使用者傳入的引數要保持警惕,對 SQL 語句要謹慎拼接,對路徑要嚴謹校驗。定期程式碼審計可以儘早發現並修復專案漏洞,給使用者更安全可靠的產品。希望通過這幾個案例,可以提醒大家在程式碼編寫過程中進一步加強安全意識。

到此本文講解完畢,感謝大家閱讀,感興趣的朋友可以點贊加關注,你的支援將是我的更新動力