作者:天融信阿爾法實驗室
原文鏈接:https://mp.weixin.qq.com/s/EOH_cj5WbSgnxP3auVhXkQ

Apache shiro簡介

Apache Shiro是一個強大且易用的Java安全框架,執行身份驗證、授權、密碼和會話管理。使用Shiro的易于理解的API,您可以快速、輕松地獲得任何應用程序,從最小的移動應用程序到最大的網絡和企業應用程序。

本文針對Shiro進行了一個原理性的講解,從源碼層面來分析了Shiro的認證和授權的整個流程,并在認證與授權的這個流程講解沖,穿插說明rememberme的作用,以及為何該字段會導致反序列化漏洞。

Apache shiro認證

在該小節中我們將會詳細講解Shiro是如何認證一個用戶為合法用戶的

Shiro漏洞環境測試代碼修改自Vulhub中的CVE-2016-4437。

首先是Shiro的配置文件,代碼如下所示

@Configuration
public class ShiroConfig {
    @Bean
    MainRealm mainRealm() {
        return new MainRealm();
    }

    @Bean
    RememberMeManager cookieRememberMeManager() {
        return (RememberMeManager)new CookieRememberMeManager();
    }

    @Bean
    SecurityManager securityManager(MainRealm mainRealm, RememberMeManager cookieRememberMeManager) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm((Realm)mainRealm);
        manager.setRememberMeManager(cookieRememberMeManager);
        return (SecurityManager)manager;
    }

    @Bean(name = {"shiroFilter"})
    ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        bean.setSecurityManager(securityManager);
        //設置登錄頁面uri
        bean.setLoginUrl("/login");
        //設置登錄失敗頁面uri
        bean.setUnauthorizedUrl("/unauth");

        Map<String, String> map = new LinkedHashMap<>();
        map.put("/doLogin", "anon");
        map.put("/doLogout", "authc");
        map.put("/user/add","perms[user:add]");
        map.put("/user/update","perms[user:update]");
        map.put("/user/delete","perms[user:delete]");
        map.put("/user/select","perms[user:select]");
        map.put("/**", "authc");

        bean.setFilterChainDefinitionMap(map);

        return bean;
    }
}

然后是Controller的代碼

@Controller
public class UserController {
    @PostMapping({"/doLogin"})
    public String doLoginPage(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam(name = "rememberme", defaultValue = "") String rememberMe) {
        Subject subject = SecurityUtils.getSubject();
        try {
            subject.login(new UsernamePasswordToken(username, password, rememberMe.equals("remember-me")));
        } catch (AuthenticationException e) {
            return "forward:/login";
        }
        return "forward:/";
    }

    @RequestMapping({"/doLogout"})
    public String doLogout() {
        Subject subject = SecurityUtils.getSubject();
        subject.logout();
        return "forward:/login";
    }

    @RequestMapping({"/"})
    public String helloPage() {
        return "hello";
    }

    @RequestMapping({"/unauth"})
    public String errorPage() {
        return "error";
    }

    @RequestMapping({"/login"})
    public String loginPage() {
        return "loginUser";
    }

    @RequestMapping({"/user/add"})
    public String add(){
        return "/user/add";
    };

    @RequestMapping({"/user/delete"})
    public String delete(){
        return "/user/delete";
    };

    @RequestMapping({"/user/update"})
    public String update(){
        return "/user/update";
    };

    @RequestMapping({"/user/select"})
    public String select(){
        Subject subject = SecurityUtils.getSubject();
        return "/user/select";
    };

}

最后是Realm

public class MainRealm extends AuthorizingRealm {

    @Autowired
    UserServiceImpl userService;

    /**該方法用來為登陸的用戶進行授權*/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        System.out.println("執行了=>授權doGetAuthorizationInfo");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        Subject subject = SecurityUtils.getSubject();
        System.out.println(subject.isAuthenticated());
        System.out.println(subject.isRemembered());
        if(!subject.isAuthenticated()){
            return null;
        }
        Users users = (Users) subject.getPrincipal();

        if(users.getPerm()!=null){
            String[] prems = users.getPerm().split(";");
            info.addStringPermissions(Arrays.asList(prems));
        }

        return info;
    }

    /**該方法用來校驗登陸的用戶*/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println("執行了=>認證doGetAuthenticationInfo");
        Subject subject= SecurityUtils.getSubject();
        System.out.println(subject.isAuthenticated());
        System.out.println(subject.isRemembered());
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
        String username = usernamePasswordToken.getUsername();
        char[] password = usernamePasswordToken.getPassword();

        Users users = userService.queryUserByName(username);

        if (users.getUsername()==null){
            return null;
        }
        return new SimpleAuthenticationInfo(users,users.getPassword(),"");

    }
}

這里來看一下自定義的MainRealm的類繼承和實現關系圖

image-20211119095107458

Realm所起到的作用通常是獲取后臺用戶的相關信息,然后獲取前端傳遞進來的用戶信息,將二者封裝好然后交由shiro進行認證比對從而判斷用戶是否為合法用戶,然后在用戶訪問后臺資源時,為用戶授予指定好的權限。

那么認證是怎么認證的呢?下面來從Shiro源碼的角度來進行詳細的分析。

首先是登陸頁面,和登陸頁面的代碼。

image-20211119105020336

當點擊Singn in按鈕的時候 后臺對應的Controller就會執行

但是在執行到Controller之前,Shiro會進行一個操作,如下所示

首先就是Shiro的Filter,在Shiro的配置文件中,通過@Bean注解讓SpringBoot在啟動的時候自動裝配了當前方法的返回值,也就是一個ShiroFilterFactoryBean對象,該對象的類繼承關系如下所示。

image-20211119153951268

該類實現了SpringFrameWork中的FactoryBean接口和BeanPostProcessor接口。SpringBoot在啟動的時候會掃描當前目錄以及子目錄下所有.java文件的注解,然后進行裝配,這一過程中就會調用FactoryBean.getObject()方法。也就是FactoryBean的實現類ShiroFilterFactoryBean.getObject()方法,

image-20211122100601384

在shiroFilter的執行的堆棧中,會創建一個Subject,Subject是Shiro中很重要的一個概念,簡單來說就是當前所操作的用戶。當前線程中的用戶所進行的認證和授權等等操作,都會以操作這個Subject對象來進行,所以Subject也被稱之為主體,最終實例化的是一個WebDelegatingSubject對象。

請求繼續往下執行,來到UserController.doLoginPage()方法,該方法中會調用Subject.login()方法,并傳入一個UsernamePasswordToken 對象。這個UsernamePasswordToken從這個類的名字我們就可以猜出這個類是用來做什么的,跟進該類中看一下

image-20211122151040458

從這個類提供的方法和屬性就可以看出來,UsernamePasswordToken類就是一個單純的pojo類,登陸時的用戶名和密碼以及對應的ip信息都會在這個類中暫時存放。

跟進Subject.login()方法,經過一系列的調用來到了ModularRealmAuthenticator.doAuthenticate,該方法會獲取我們自定義的Realm并一次進行調用,我們自定義的Realm是文章開頭的MainRealm,所謂的Realm,就是對傳入的用戶進行認證和授權的地方,Realm的自定義需要繼承自AuthorizingRealm,Realm我們可以自定義多個,只需要將自定義好的多個Realm放入一個Collection對象中,然后在配置文件中通過SecurityManager.setRealms()傳入,這樣在Shiro在認證時就會依次調用我們自定義的Realms,Shiro本身也自帶有一些Reamls可以直接調用,如下圖所示

image-20211122155220256

自定義的Realm有兩個方法必須要實現,分別是繼承自AuthencationgRealm的doGetAuthenticationInfo()方法,和AuthorizingRealm的doGetAuthorizationInfo方法,如下圖所示

image-20211122160621157

下面根據程序執行流程,先講doGetAuthenticationInfo,根據之前所講調用subject.login()方法時會調用到我們自定義的Realm的doGetAuthenticationInfo方法,我們在該方法中的實現非常簡單,即從后臺數據庫中根據用戶名進行查詢用戶是否存在,如果存在則將查詢出來的數據封裝成Users對象,然后將封裝好的Users對象傳入和查詢出的該用戶的密碼一同傳入SimpleAuthenticationInfo類構造方法中并進行返回。

這一步說是用來進行用戶的認證,但是不難發現,該方法中并沒有對用戶的密碼進行校驗,那么真正的校驗點在哪里呢,在如下圖所示的位置

image-20211123153358390

在AuthenticatingRealm的getAuthenticationInfo方法中不僅調用了我們自定義的MainRealm中的doGetAuthenticationInfo方法,還調用了自身的assertCredentialsMatch方法,如下圖所示,而assertCredentialsMatch方法就是用來校驗前端傳遞來的用戶名和密碼,以及后臺從數據庫查詢出的密碼進行比對的。

image-20211123153558775

在assertCredentialsMatch方法中跟如cm.doCredentialsMatch(token, info),然后就可以看到shiro如何進行用戶密碼比對的了。

image-20211123154312641

token是前端傳入的用戶名和密碼封裝成的UsernamePasswordToken對象,info是從數據庫中查詢出的數據封裝成的SimpleAuthenticationInfo對象,如此一來,獲取二者的密碼,進行equals比對,相同則程序繼續執行,不相同則拋出異常,返回登陸界面。

那么Shiro認證到這里就結束了么?當然不是,之前提到過,Shiro中有一個概念叫Subject,Subject代表的就是用戶當前操作的主體,在這第一次登陸認證中我們也是通過調用了一個Subject對象的login方法才進行的身份驗證,但是在這個Subject中是沒有任何的用戶信息的,當用戶的信息通過校驗之后,Shiro又會實例化一個WebDelegatingSubject,而這個位置就在DefaultSecurityManager的login方法中,如下圖所示

image-20211126105726397

我們之前看到的認證過程就在authenticate方法里,身份真正成功后會返回用戶的信息,封裝在一個SimplePrincipalCollection對象里,如果認證失敗,則會拋出異常。

認證成功后,Shiro就會根據當前用戶的一些信息,再創建一個Subject,后續該用戶進行的任何操作都會以這個Subject為主,授權也是Shiro給這個Subject進行授權。

如此以來我們就了解了Shiro是如何認證一個用戶的,下面來總結一下Shiro認證用戶的一個思路,首先在用戶沒有進行認證的時候訪問一些資源,Shiro會生成一個Subject,這個Subject沒有任何的用戶信息。當用戶開始登陸,Shiro會調用Subject的login方法,對用戶的用戶名和密碼進行校驗,校驗通過后,會生成一個新的Subject,后續用戶的授權等操作,都會基于這個新生成的Subject。

Apache Shiro授權

看完了Shiro的認證過程,接下來我們來看Shiro的授權過程。

我們將每位用戶所擁有的授權都存入數據庫中如下所示

image-20211129101606539

這里以admin為例,來分析下Shiro授權的過程。

書接上文Shiro認證部分,認證完成功Shiro會生成一個新的Subject,而shiro的授權過程也就是圍繞著這個Subject來進行的,那么Shiro何時會為用戶進行授權行為呢?

在之前的內容中說過,自定義的Realm有兩個方法必須要實現,分別是繼承自AuthencationgRealm的doGetAuthenticationInfo()方法,和AuthorizingRealm的doGetAuthorizationInfo方法。

doGetAuthenticationInfo()方法我們已經清楚了,是用來進行用戶身份驗證的,而doGetAuthorizationInfo()方法就是用來進行用戶授權的。

再回顧一下之前的配置文件中我們為每個資源所授予的訪問權限,權限如下所示。

 @Bean(name = {"shiroFilter"})
    ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        bean.setSecurityManager(securityManager);
        bean.setLoginUrl("/login");
        bean.setUnauthorizedUrl("/unauth");
        /**
         * anon:無需認證就可以訪問
         * authc: 必須認證了才能訪問
         * user: 必須擁有記住我功能才能訪問
         * perms: 擁有對某個資源的權限才能訪問
         * role: 擁有某個角色權限才能訪問
         * */
        Map<String, String> map = new LinkedHashMap<>();
        map.put("/doLogin", "anon");
        map.put("/doLogout", "authc");
        map.put("/user/add","perms[user:add]");
        map.put("/user/update","perms[user:update]");
        map.put("/user/delete","perms[user:delete]");
        map.put("/user/select","perms[user:select]");
        map.put("/**", "authc");

        bean.setFilterChainDefinitionMap(map);

        return bean;
    }

我們在doGetAuthorizationInfo()方法中下斷點,當已經經過認證的用戶訪問制定資源的時候,shiro就會調用doGetAuthorizationInfo()方法來為該用戶進行授權,具體怎么執行到該方法的就不細說了,將調用鏈粘貼一下。

image-20211129110607701

doGetAuthorizationInfo()方法的具體實現如下所示

    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        String name = getName();
        System.out.println("執行了=>授權doGetAuthorizationInfo");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
      //獲取當前用戶的subject
        Subject subject = SecurityUtils.getSubject();
        System.out.println(subject.isAuthenticated());
        System.out.println(subject.isRemembered());
        if(!subject.isAuthenticated()){
            return null;
        }
//        Users users = userService.queryUserByName((String) subject.getPrincipal());
      //
      //獲取當前用戶的信息
        Users users = (Users) subject.getPrincipal();
            //判斷當前用戶的權限字段是否為空,如果不為空的話就傳入SimpleAuthorizationInfo的addStringPermissions方法中。
        if(users.getPerm()!=null){
            String[] prems = users.getPerm().split(";");
            info.addStringPermissions(Arrays.asList(prems));
        }

        return info;
    }

之前在認證的那一步中,我們將數據庫中的數據封裝成一個Users對象,該對象存放入了Subject中,doGetAuthorizationInfo()方法中我們將其取出。

在該方法中,我們所做的只是將用戶數據庫中的權限字段取出然后封裝入一個SimpleAuthorizationInfo對象中,并進行返回,我們跟隨看一下Shiro后續的操作。

在獲取完當前用戶的權限后,堆棧返回到AuthorizingRealm的isPermitted方法中,該方法又調用了isPermitted()方法,isPermitted()方法就是用來判斷用戶是否有權限訪問指定資源的方法。

isPermitted()方法具體內容如下所示

image-20211129144006454

該方法會講用戶所擁有的權限循環遍歷出來,然后和當前資源所需要訪問權限進行一一比對,如果相同則返回true。那么比對規則是怎樣的呢?

跟進implies()方法,內容如下所示

image-20211129144650676

這里簡述一下比對的規則,當前資源所需的訪問權限[user:add],對于shiro來說所謂的訪問權限不過就是一串字符串而已,shiro會將[user:add]以“:”進行分割,分割成user和add兩個字符串,而假如用戶具有[user:add],和[user:select]這兩個權限,第一次循環就是判斷[user:select]和[user:add]是否相同,會首先判斷“:”之前的字符串是否相同,也就是user這部分是否相同,相同則繼續,不相同則返回false。判斷相同以后,會第二次循環判斷“:”之后的部分是否相同,也就是add和select。那自然是不相同的,所以返回false。shiro接下來會繼續判斷[user:add]和[user:add]是否相同。

這就是shiro授權和鑒權的代碼流程,也是shiro的核心。了解了shiro的這部分內容之后,我們接下來就該講CVE-2016-4437這個漏洞的具體內容了。

Apahce Shiro反序列化漏洞的根源

shiro在用戶登陸的時候,除了用戶名和密碼以外 還有一個可傳遞的選項,也就是shiro發序列化漏洞產生的根源,Rememberme。

Rememberme的核心作用時什么呢?就是用戶在登錄時勾選rememberme選項,Cookie中就會增加一個rememberme字段,該字段中會存儲一些序列化數據,開發者可以指定rememberme字段的有效時間,同時開發者可以指定一些資源,這些資源允許攜帶rememberme字段的用戶訪問,由于rememberme是存儲在瀏覽器中的,并在用戶的每一次請求中被攜帶,所以只要不清除Cookie,用戶就可以在rememberme的有效時間內,無需再次登陸,就可以訪問指定資源。在不勾選rememberme的情況下,通常就是瀏覽器關閉,會話就會立刻結束,活著等待一段時間后結束,屆時用戶想要訪問一些資源則需要重新登陸,勾選rememberme后,即使推出瀏覽器結束與服務端的會話,rememberme仍然存儲在瀏覽器中,重新打開瀏覽器訪問指定資源,瀏覽器在請求時仍會攜帶上rememberme,如此一來就不需要重新登陸了。

那么接下來就來分析rememberme是如何生成,以及如何實現無需登錄即可訪問指定資源的。

如果登陸的時候不勾選rememberme選項的情況下,Shiro是不會生成rememberme的,勾選了rememberme選項后,才會在認證的過程中生成該值。

生成rememberme的位置在DefaultSecurityManager的login方法中,如下圖所示。

image-20211130134446456

位置就是在Shiro完成用戶認證,生成一個新的Subject之后。跟進onSuccessfulLogin()方法,經過嵌套調用,來到AbstractRememberMeManager的onSuccessfulLogin方法,

image-20211130140100125

在該方法中,會先判斷此次請求中remebmberme字段是否存在,如果存在則調用rememberIdentity()方法,想要知道rememberme中存儲了什么東西那么就要繼續深入。

image-20211202141700873

這里是獲取到了一個PrincipalCollection對象,繼續深入。

image-20211202150005291

接下來就是將這個PrincipalCollection轉化成byte數組。這個方法很關鍵,我們需要跟入看一下

image-20211202150731294

看到這里大家應該就明白了,Shiro為什么會有反序列化漏洞,以及rememberme所傳遞的數據就究竟是什么,其實就是一個序列化后的PrincipalCollection對象,而這個encrypt就是通過AES來加密序列化后的數據,密鑰呢?當然就是硬編碼在AbstractRememberMeManager類中的這段base64編碼后的字符串了,如下圖所示

image-20211202151111219

最后的最后,Shiro會將這段加密后的數據base64編碼一遍,然后放入Cookie中,至此Shiro生成rememberme的過程就結束了

image-20211202151529398

那么知道了rememberme是怎么加密生成的,那么自然也就可以很輕易的解密了,尤其還是在密鑰硬編碼在代碼中的這種情況,下面是解密的demo

image-20211202152026799

那么反序列化漏洞產生的點在哪里呢?當用戶在登錄時勾選了rememberme的時候,Shiro會返回一個rememberme通過Cookie字段傳遞,然后存儲在瀏覽器中,正常情況下當用戶關閉瀏覽器或者手動刪除瀏覽器中存儲的SesseionID的時候,與服務端的當前會話就結束了,當下次打開瀏覽器再此訪問服務端的時候,就需要重新登錄。而勾選了rememberme的用戶登錄后,關閉瀏覽器后,會話同樣會關閉,但是下次打開瀏覽器請求訪問服務端的時候,Cookie中會攜帶Rememberme來進行請求,從而達到無需登錄的效果。

漏洞的產生關鍵就在于再重新建立會話的這個過程,所以想要觸發rememberme的話請求包中就不能有SessionID,刪除SessionID后再攜帶rememberme進行一次請求就可以觸發rememberme的反序列化點,如下圖所示

image-20211207141916231

最終通過base64解碼,然后AES解密,解密后的結果再經過反序列化,就還原成了一個PrincipalCollection對象,至此Shiro rememberme的生成以及作用,還有如何觸發rememberme反序列化,都已講解完畢。

image-20211207142419295

總結

Apache Shiro是一款相當優秀的認證授權框架,雖然在護網等大型攻防項目中,經常被作為突破口,但是仍然瑕不掩瑜,shiro的反序列化在流量識別中是比較容易判斷的,因為序列化數據的傳遞必須要通過Cookie中的rememberme字段,但是縱使識別出來,但是如果不知道密鑰的話,也無法得知傳遞的內容。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1782/