一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權

語言: CN / TW / HK

一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權

碼農唐磊 程式猿石頭
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權

之前在某廠的某次專案開發中,專案組同學設計和實現了一個“引以為傲”,額,有點擴張,不過自認為還說得過去的 feature,結果臨上線前被啪啪打臉,因為實現過程中因為一行程式碼(沒有標題黨,真的是一行程式碼)帶來的安全漏洞讓我們丟失了整個伺服器控制權(測試環境)。多虧了上線之前有公司安全團隊的人會對程式碼進行掃描,才讓這個漏洞被扼殺在搖籃裡。

下面我們就一起來看看這個事故,啊,不對,是故事。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權

背景說明


我們的專案是一個面向全球使用者的 Web 專案,用 SpringBoot 開發。在專案開發過程中,離不開各種異常資訊的處理,比如表單提交引數不符合預期,業務邏輯的處理時離不開各種異常資訊(例如網路抖動等)的處理。於是利用 SpringBoot 各種現成的元件支援,設計了一個統一的異常資訊處理元件,統一管理各種業務流程中可能出現的錯誤碼和錯誤資訊,通過國際化的資源配置檔案進行統一輸出給使用者。

統一錯誤資訊配置管理


我們的使用者遍佈全球,為了給各個國家使用者比較好的體驗會進行不同的翻譯。具體而言,實現的效果如下,為了方便理解,以“找回登入密碼”這樣一個業務場景來進行闡述說明。
假設找回密碼時,需要使用者輸入手機或者郵箱驗證碼,假設這個時候使用者輸入的驗證碼通過後臺數據庫(可能是Redis)對比發現已經過期。在業務程式碼中,只需要簡單的 throw new ErrorCodeException(ErrorCodes.AUTHCODE_EXPIRED) 即可。具體而言,針對不同國家地區不同的語言看到的效果不一樣:

  • 中文使用者看到的提示就是“您輸入的驗證碼已過期,請重新獲取”;
  • 歐美使用者看到的效果是“The verification code you input is expired, ...”;
  • 德國使用者看到的是:“Der von Ihnen eingegebene Verifizierungscode ist abgelaufen, bitte wiederholen” 。(我瞎找的翻譯,不一定準)
  • ……

**統一錯誤資訊配置管理程式碼實現


**
關鍵資訊其實就在於一個 GlobalExceptionHandler,對所有 Controller 入口進行 AOP 攔截,根據不同的錯誤資訊,獲取相應資原始檔配置的 key,並從語言資原始檔中讀取不同國家的錯誤翻譯資訊。


@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadRequestException.class)
    @ResponseBody
    public ResponseEntity handle(HttpServletRequest request, BadRequestException e){
        String i18message = getI18nMessage(e.getKey(), request);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Response.error(e.getCode(), i18message));
    }

    @ExceptionHandler(ErrorCodeException.class)
    @ResponseBody
    public ResponseEntity handle(HttpServletRequest request, ErrorCodeException e){
        String i18message = getI18nMessage(e.getKey(), request);
        return ResponseEntity.status(HttpStatus.OK).body(Response.error(e.getCode(), i18message));
    }
}

一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
不同語言的資原始檔示例


private String getI18nMessage(String key, HttpServletRequest request) {
   try {
       return messageSource.getMessage(key, null, LanguaggeUtils.currentLocale(request));
   } catch (Exception e) {
       // log
       return key;
   }
}

詳細程式碼實現可以參考本人之前寫的這篇文章一文教你實現 SpringBoot 中的自定義 Validator 和錯誤資訊國際化配置,上面有附完整的程式碼實現。

**基於註解的表單校驗(含自定義註解)


**
還有一種常見的業務場景就是後端介面需要對使用者提交的表單進行校驗。以“註冊使用者”這樣的場景舉例說明, 註冊使用者時,往往會提交暱稱,性別,郵箱等資訊進行註冊,簡單起見,就以這 3 個屬性為例。

定義的表單如下:


public class UserRegForm {
 private String nickname;
 private String gender;
 private String email;
}

對於表單的約束,我們有:

  • 暱稱欄位:“nickname” 必填,長度必須是 6 到 20 位;
  • 性別欄位:“gender” 可選,如果填了,就必須是“Male/Female/Other/”中的一種。(說啥,除了男女還有其他?對,是的。畢竟全球使用者嘛,你去看看非死不可,還有更多。)
  • 郵箱:“email”,必填,必須滿足郵箱格式。

對於以上約束,我們只需要在對應的欄位上新增如下註解即可。


public class UserRegForm {
 @Length(min = 6, max = 20, message = "validate.userRegForm.nickname")
 private String nickname;

 @Gender(message="validate.userRegForm.gender")
 private String gender;

 @NotNull
 @Email(message="validate.userRegForm.email")
 private String email;
}

然後在各個語言資原始檔中配置好相應的錯誤資訊提示即可。其中, @Gender 就是一個自定義的註解。

基於含自定義註解的表單校驗關鍵程式碼


自定義註解的實現主要的其實就是一個自定義註解的定義以及一個校驗邏輯。例如定義一個自定義註解 CustomParam:


@Documented
@Constraint(validatedBy = CustomValidator.class)
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomParam {
    String message() default "name.tanglei.www.validator.CustomArray.defaultMessage";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default { };

    @Documented
    @Retention(RetentionPolicy.RUNTIME)
    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @interface List {
        CustomParam[] value();
    }
}

校驗邏輯的實現 CustomValidator:


public class CustomValidator implements ConstraintValidator<CustomParam, String> {
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
        if (null == s || s.isEmpty()) {
            return true;
        }
        if (s.equals("tanglei")) {
            return true;
        } else {
            error(constraintValidatorContext, "Invalid params: " + s);
            return false;
        }
    }

    @Override
    public void initialize(CustomParam constraintAnnotation) {
    }

    private static void error(ConstraintValidatorContext context, String message) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
    }
}

上面例子只為了闡述說明問題,其中校驗邏輯沒有實際意義,這樣,如果輸入引數不滿足條件,就會明確提示使用者輸入的哪個引數不滿足條件。例如輸入引數xx,則會直接提示:Invalid params: xx。

一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)
這個跟第一部分的處理方式類似,因為現有的 validator 元件實現中,如果違反相應的約束也是一種拋異常的方式實現的,因此只需要在上述的 GlobalExceptionHandler中新增相應的異常資訊即可,這裡就不詳述了。這不是本文的重點,這裡就不詳細闡述了。詳細程式碼實現可以參考本人之前寫的這篇文章一文教你實現 SpringBoot 中的自定義 Validator 和錯誤資訊國際化配置,上面有附完整的程式碼實現。

場景重現


一切都顯得很完美,直到上線前程式碼提交至安全團隊掃描,就被“啪啪打臉”,掃描報告反饋了一個嚴重的安全漏洞。而這個安全漏洞,屬於很高危的遠端程式碼執行漏洞。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
用前文提到的自定義 Validator,輸入的引數用:“1+1=${1+1}”,看看效果:
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)
太 TM 神奇了,居然幫我運算出來了,返回"message": "Invalid params: 1+1=2"。
問題就出現在實現自定義註解進行校驗的這行程式碼(如下圖所示):
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
其實,最開始的時候,這裡直接返回了“Invalid params”,當初為了更好的使用者體驗,要明確告訴使用者哪個引數沒有通過校驗,因此在輸出的提示上加上了使用者輸入的欄位,也就是上面的"Invalid params: " + s,沒想到,這闖了大禍了(回過頭來想,感覺這裡沒必要這麼詳細啊,因為前端已經有相應的校驗了,正常情況下回攔住,針對不守規矩的用非常規手段來的介面請求,直接返回校驗不通過就行了,畢竟不是對外提供的 OpenAPI 服務)。
仔細看,這個方法實際上是ConstraintValidatorContext這個介面中宣告的,看方法名字其實能知道輸入引數是一個字串模板,內部會進行解析替換的(這其實也符合“見名知意”的良好程式設計習慣)。(教訓:大家應該把握好自己寫的每一行程式碼背後實際在做什麼。)









/* ......
 * @param messageTemplate new un-interpolated constraint message
 * @return returns a constraint violation builder
 */
ConstraintViolationBuilder buildConstraintViolationWithTemplate(String messageTemplate);

這個 case,原始碼除錯進去之後,就能跟蹤到執行翻譯階段,在如下方法中:org.hibernate.validator.messageinterpolation.AbstractMessageInterpolator.interpolateMessage。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)
再往後,就是表示式求值了。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權



以為就這樣就完了嗎?


一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權

剛開始感覺,能幫忙算簡單的運算規則也就完了吧,你還能把我怎麼樣?其實這個相當於暴露了一個入口,支援使用者輸入任意 EL 表示式進行執行。網上通過關鍵字 “SpEL表示式注入漏洞” 找找,就能發現事情並沒有想象中那麼簡單。
我們構造恰當的 EL 表示式(注意各種轉義,下文的輸入引數相對比較明顯在做什麼了,實際上還有更多黑科技,比如各種二進位制轉義編碼啊等等),就能直接執行輸入程式碼,例如:可以直接執行命令,“ls -al”, 返回了一個 UNIXProcess 例項,命令已經被執行過了。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)
比如,我們執行個開啟計算器的命令,搞個計算器玩玩~
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)
我錄製了一個動圖,來個演示可能更生動一些。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
這還得了嗎?這相當於直接在公網上提供了一個 WebShell 的功能呀,你看,想執行啥命令就能執行啥命令,例如 ping 本人部落格地址(ping www.tanglei.name),下面動圖(gif 圖上傳總是失敗,試試微信公眾號嵌入影片功能)演示一下整個過程(從執行 ping 到 kill ping)。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(影片全屏播放看得更清楚)
這樣豈不是直接建立一個使用者,然後遠端登入就可以了。後果非常嚴重啊,別人想幹嘛就幹嘛了。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權












漏洞根因


我們跟蹤下對應的程式碼,看看內部實現,就會“恍然大悟”了。
一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)

一行程式碼引來的安全漏洞就讓我們丟失了整個伺服器的控制權
(圖片放大看得更清楚)

經驗教訓


幸虧這個漏洞被扼殺在搖籃裡,否則後果還真的挺嚴重的。通過這個案例,我們有啥經驗和教訓呢?那就是作為程式設計師,我們要對每一行程式碼都保持“敬畏”之心。也許就是因為你的不經意的一行程式碼就帶來了嚴重的安全漏洞,要是不小心被壞人利用,輕則……重則……(自己想象吧)

此外,我們也應該看到,程式設計師需要對常見的安全漏洞(例如XSS/CSRF/SQL注入等等)有所瞭解,並且要有足夠的安全意識(其實有時候研究一些安全問題還挺好玩的,比如這篇《RSA演算法及一種"旁門左道"的***方式》就比較有趣)。例如:

  • 使用者許可權分離:執行程式的使用者不應該用 root,例如新建一個“web”或者“www”之類的使用者,並設定該使用者的許可權,比如不能有可執行 xx 的許可權之類的。本文 case,如果許可權進行了分離(遵循最小許可權原則),應該也不會這麼嚴重。(本文就剛好是因為是測試環境,所以沒有強制實施)
  • 任何時候都不要相信使用者的輸入,必須對使用者輸入的進行校驗和過濾,又特別是針對公網上的應用。
  • 敏感資訊加密儲存。退一萬步講,假設***者攻入了你的伺服器,如果這個時候,你的資料庫賬戶資訊等配置都直接明文儲存在伺服器中。那資料庫也被脫走了。
    如果可能的話,需要對開發者的程式碼進行漏洞掃描。一些常見的安全漏洞現在應該是有現成的工具支援的。另外,讓專業的人做專業的事情,例如要有安全團隊,可能你會說你們公司沒有不也活的好好的,哈哈,只不過可能還沒有被壞人盯上而已,壞人也會考慮到他們的成本和預期收益的,當然這就更加對我們開發者提高了要求。一些敏感權限盡量控制在少部分人手中,配合相應的流程來支撐(不得不說,大公司繁瑣的流程還是有一定道理的)。

畢竟我不是專業研究Web安全的,以上說得可能也不一定對,如果你有不同意見或者更好的建議歡迎留言參與討論。

這篇文章從寫程式碼做實驗,到錄屏做影片動圖等等耗時還蠻久的(好幾個週末的時間呢),原創真心不易,希望你能幫我個小忙唄,如果本文內容你覺得有所啟發,有所收穫,請幫忙點個“在看”唄,或者轉發分享讓更多的小夥伴看到。