原文鏈接:https://www.leavesongs.com/PENETRATION/jumpserver-sep-2023-multiple-vulnerabilities-go-through.html
作者:Phith0n
Jumpserver是中國國內公司開發的一個開源項目,在開源堡壘機領域一家獨大。在2023年9月官方集中修復了一系列安全問題,其中涉及到如下安全漏洞:
- JumpServer 重置密碼驗證碼可被計算推演的漏洞,CVE編號為CVE-2023-42820
- JumpServer 重置密碼驗證碼可被暴力破解的漏洞,CVE編號為CVE-2023-43650
- JumpServer 認證用戶跨目錄任意文件讀取漏洞,CVE編號為CVE-2023-42819 - JumpServer 全局開啟公鑰認證后,用戶可以使用公鑰創建訪問Token的漏洞,CVE編號為CVE-2023-43652
- JumpServer 認證用戶開啟MFA后,可以使用SSH公鑰認證的邏輯缺陷漏洞,CVE編號為CVE-2023-42818
- JumpServer 認證用戶連接MongoDB數據庫,可執行任意系統命令的遠程執行漏洞,CVE編號為CVE-2023-43651
- Jumpserver Session錄像任意下載漏洞,CVE編號為CVE-2023-42442
雖然涉及到數個漏洞,也不乏高危嚴重問題,但對于一個商業化運營的國產開源項目來說,官方透明公開的態度還是值得點贊的。另外官方寫的漏洞通告也很詳細,那么我們就根據漏洞通告的內容依舊補丁來逐一解析一些這些漏洞的詳情吧。
CVE-2023-42820:為隨機數種子泄漏造成用戶接管漏洞
這個漏洞是這一系列漏洞里最嚴重的問題了,未授權的攻擊者可以利用該漏洞推算出沒有開啟多因子驗證(MFA)的賬號的“重置密碼Token”,進而修改該賬號的密碼。
這句話很繞,簡單來說就是用戶點擊忘記密碼時系統會生成一個隨機字符串作為Token并發送到用戶郵箱,但由于一個有趣的安全問題,導致這個隨機字符串Token可以被推算出來,造成漏洞。
漏洞的核心是隨機數種子泄露導致的,而這段邏輯并不是來自于Jumpserver,而是其依賴的一個第三方項目django-simple-captcha。
這個django-simple-captcha庫和Django reCAPTCHA可以說是Django生態中唯二常用的驗證碼生成庫了,但因為中國用戶無法使用reCAPTCHA,所以它基本就是國內的唯一之選。包括我自己的博客也在使用其作為圖形驗證碼依賴:

今年我寫了一篇文章《用ChatGPT幫我檢查廣告評論》,起因就是當時遇到了大量垃圾評論,我懷疑是我的驗證碼被繞過了,但由于自己沒有去看django-simple-captcha的代碼,當時只猜測是攻擊者“識別”了驗證碼內容,但現在看來也可能是由于隨機數種子漏洞導致的。
對,使用django-simple-captcha后,你的進程隨機數種子將泄露給用戶。
我們來看看django-simple-captcha的工作流程。首先,開發者需要為需要驗證碼的Django表單(forms)增加一個CaptchaField字段:
class UserForm (forms.Form):
username = forms.CharField(...)
captcha = CaptchaField(widget=CustomCaptchaTextInput, label=_('Captcha'))
...
渲染表單的時候,CaptchaField內部會生成隨機的驗證字符串(challenge)和答案(response)。challenge可以是傳統的4個字符的文本,也可以是我博客或Jumpserver中使用的四則運算,這個通過配置來定義。
然后,challenge和response會按照如下算法生成一個唯一的hashkey:
randrange = random.SystemRandom().randrange
key_ = (
smart_text(randrange(0, MAX_RANDOM_KEY))
+ smart_text(time.time())
+ smart_text(self.challenge, errors="ignore")
+ smart_text(self.response, errors="ignore")
).encode("utf8")
self.hashkey = hashlib.sha1(key_).hexdigest()
這個hashkey將返回給用戶,用戶通過這個hashkey和下面的captcha_image視圖生成驗證碼圖片。
在頁面中展示驗證碼時,django-simple-captcha提供了一個captcha_image視圖,開發者需要將其加入url routers中:

captcha_image視圖只接收一個參數,即為用戶傳入的key:
def captcha_image (request, key, scale=1):
if scale == 2 **and not settings.CAPTCHA_2X_IMAGE:
raise Http404
try:
store = CaptchaStore.objects.get(hashkey=key)
except** CaptchaStore.DoesNotExist:
# HTTP 410 Gone status so that crawlers don't index these expired urls.
return HttpResponse(status=410)
random.seed(key) # Do not generate different images for the same key
#...
這里的key就是前面計算的驗證碼hashkey,作用是通過它查找到數據庫中對應的驗證碼。如果驗證碼存在,則將key傳給random.seed函數并執行后續操作,后面的代碼多次調用了偽隨機數相關函數,用于給驗證碼圖片生成旋轉、噪點。
問題就出在這里了,開發者使用random.seed(key)的目的是將后續操作中的隨機因素固定,保證key相同的情況下生成的驗證碼圖片完全相同。但因為key是一個用戶已知的值,那么用戶就可以用于預測后續生成的所有的偽隨機數。
這個過程中有兩點值得注意:
- 我們并不可以“篡改”或“控制”隨機數種子,而只可以“查看”隨機數種子,因為key的生成過程是不可控的
- 在調用
random.seed(key)以前驗證碼就已經生成好了,這里設置隨機數種子的目的只是為了讓驗證碼圖片中的旋轉和噪點保持一致
這兩點并不影響我們的漏洞利用,因為攻擊者已知了此時的隨機數種子,就可以預測后續所有的偽隨機數生成函數的結果——不僅包括django-simple-captcha后續生成的驗證碼,也包括jumpserver中其他使用了偽隨機數的場景。
這個漏洞的另一個核心點就是Jumpserver內部使用偽隨機數來生成找回密碼時的Token。雖然Python官方文檔中明確申明不要以安全為目的使用random模塊,并且在提供了secrets模塊作為替代選項:
Warning The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the
secretsmodule.
但是不幸的是Jumpserver仍然使用的random模塊來生成Token:

那么答案就呼之欲出了,使用第三方模塊django-simple-captcha提供的方式獲取固定在進程中的偽隨機數種子,然后馬上請求UserResetPasswordSendCodeApi生成找回密碼使用的code,通過預測這個code修改目標用戶密碼。
漏洞利用過程沒有太復雜,只是需要注意幾個點:
- Jumpserver以多進程負債均衡的方式運行,而每次請求只會固定當前進程的偽隨機數種子,所以我們需要嘗試發送數個相同的請求將所有進程中的種子固定
- 進入找回密碼步驟本身也需要輸入驗證碼,如果編寫自動化程序,也可以嘗試使用這個方法來預測驗證碼
為了讓讀者更加理解漏洞的本質,我編寫了半自動的腳本來簡化步驟,這部分內容已上線Vulhub:https://github.com/vulhub/vulhub/tree/master/jumpserver/CVE-2023-42820,可以參閱復現。
這里再說下修復方法,我推薦如下三種情況:
- 如果不關注原
random_string函數函數中的功能,可以簡單將其替換成Django內置的django.utils.crypto.get_random_string - 如果仍想保留原
random_string函數函數中的功能,也可以將函數中的random模塊換成secrets模塊,但需要Python版本3.6及以上 - 如果想兼容3.6以下的Python,可以使用操作系統提供的隨機數發生器
random.SystemRandom()
當然,現在官方使用的修復方案在大部分情況下也沒什么問題:

將偽隨機數種子設置成None背后發生的事情,以及如何調試CPython底層C代碼來理解其運行原理,可以參考我在星球的這篇帖子:《CPython底層調試 - random.seed背后發生了什么》。
CVE-2023-43650:重置密碼Token可爆破漏洞
這個漏洞和上一個漏洞略有不同。我們在進入重置密碼頁面的時候,需要輸入一次驗證碼:

輸入成功后會跳轉到第二個頁面,用于找回密碼:

如果你看過Vulhub中復現CVE-2023-42820的過程,你應該記得這兩個步驟——用戶在第一個頁面輸入賬號和驗證碼后,會生成一個隨機的Token附于跳轉URL中:
class UserForgotPasswordPreviewingView(FormView):
template_name = 'users/forgot_password_previewing.html'
form_class = forms.UserForgotPasswordPreviewingForm
@staticmethod
def get_redirect_url(token):
return reverse('authentication:forgot-password') + '?token=%s' % token
def form_valid(self, form):
username = form.cleaned_data['username']
user = get_object_or_none(User, username=username)
...
token = random_string(36)
user_map = {'username': user.username, 'phone': user.phone, 'email': user.email}
cache.set(token, user_map, 5 * 60)
return redirect(self.get_redirect_url(token))
這個Token會保存在緩存里,過期時間是5分鐘。也就是說,5分鐘內使用這個Token訪問第二個密碼找回的頁面,都不需要再次輸入驗證碼。
而第二個頁面中會生成6位數字的Verify Code,所以我們可以直接爆破這個Code。
官方對于這個漏洞的修復方法是,在驗證Verify Code的時候限制次數,超過三次則強制過期這個Code:

但我理解這個修復方法其實不能解決本質問題,因為本質問題是前面的驗證碼Token沒有過期,而非這個Verify Code沒有過期。換句話說,最新版本的代碼中,攻擊者仍然可以爆破Verify Code——只需不斷生成新的Verify Code,然后用同一個Code(如123456)來嘗試,總能遇到某次生成的Verify Code與123456相等,最后修改用戶密碼。
CVE-2023-42819:Playbook任意文件讀取/寫入漏洞
Jumpserver使用ansible來管理主機,playbook是ansible中用于管理主機的機制,使用者可以通過編寫基于YAML的配置文件來下發任務到主機中。
我們在進入Jumpserver后臺以后,有很多地方可以用來執行命令或讀寫文件。但我們需要區分這個命令是在哪個機器上執行的:用戶推送命令到自己管理的主機中執行,這個是符合預期的行為,但是如果用戶在Jumpserver這臺堡壘機服務器上執行了任意命令或讀寫任意文件,這就是非預期的漏洞了。
這個漏洞就是后者,但它其實和playbook沒啥關系,主要問題還是出在Jumpserver本身的代碼中。Jumpserver支持用戶在Web頁面中上傳、下載、瀏覽playbook模板文件,比如ops.api.playbook.PlaybookFileBrowserAPIView這個視圖:
class PlaybookFileBrowserAPIView(APIView):
def get(self, request, **kwargs):
playbook_id = kwargs.get('pk')
playbook = get_object_or_404(Playbook, id=playbook_id)
work_path = playbook.work_dir
file_key = request.query_params.get('key', '')
if file_key:
file_path = os.path.join(work_path, file_key)
with open(file_path, 'r') as f:
try:
content = f.read()
except UnicodeDecodeError:
content = _('Unsupported file content')
return Response({'content': content})
else:
expand_key = request.query_params.get('expand', '')
nodes = self.generate_tree(playbook, work_path, expand_key)
return Response(nodes)
這里在獲取用戶輸入file_key后直接使用os.path.join(work_path, file_key)來拼接路徑并讀取文件。那么就可以直接使用../../../../../etc/passwd或/etc/passwd進行目錄穿越并讀取任意文件。
為什么可以直接使用
/etc/passwd而不需要../,請參考我在2017年分享在星球的帖子:https://t.zsxq.com/122oWQ0a2
嘗試復現這個漏洞,我們需要先創建一個Playbook。位置在“工作臺 -> 作業中心 -> 模板管理 -> Playbook管理 -> 創建Playbook”,創建后會獲得一個uuid格式的id:

然后直接訪問http://your-ip:8080/api/v1/ops/playbook/[uuid]/file/?key=/etc/passwd即可讀取到passwd文件:

寫文件也在同一個視圖中,只不過是POST方法:

我在這里向/etc/cron.d中寫入了一個文件,文件名是rce,內容是計劃任務。
文件寫入成功后,即可看到touch /tmp/success已成功執行:

值得注意的有兩個問題:
- 由于這個數據包是POST方法,所以需要增加CSRF頭
X-CSRFToken,只需要讓其值和Cookie中的jms_csrftoken相同即可(關于Django中的Cookie based CSRF的原理,可以參考我的這篇文章《Cookie-Form型CSRF防御機制的不足與反思》) - 計劃任務需要以空白行結尾,增加一個
\n即可
利用CVE-2023-42820和CVE-2023-42819兩個漏洞,我們就可以實現從一個無權限的游客到最后Getshell的完整過程。
CVE-2023-43652:公鑰信任邏輯造成用戶接管漏洞
這也是一個比較有趣的問題。
講清楚這個問題前,需要先簡單了解一下Jumpserver的架構。完整的社區版Jumpserver包含下面這些組件:
-
Core 組件是 JumpServer 的核心組件,其他組件依賴此組件啟動
- Lina是Core的前端
-
Koko 是服務于類 Unix 資產平臺的組件,通過 SSH、Telnet 協議提供字符型連接
- Luna是Koko的前端
-
Lion 是圖形協議的組件,用于 Web 端訪問 RDP、VNC 等服務
- 其底層依賴于Apache Guacamole Server
-
Magnus 數據庫網關,用于用戶連接訪問常見數據庫
-
Chen 數據庫Web管理組件,用戶可以使用Web端管理各種數據庫
-
Kael 連接GPT資產的組件(實際上和堡壘機項目沒啥關系了)
-
Celery 是處理異步任務的組件,用于執行 JumpServer 相關的自動化任務
-
Nginx Web網關
其中,Koko提供了對于SSH的支持。用戶連接上Koko的SSH終端后,會進入一個一個主機選擇的頁面,我們在其中選擇實際要連接的目標服務器,這就是堡壘機的核心功能之一:

在Jumpserver的架構中,只有Core服務會連接數據庫,那么對于Koko這樣的組件,就需要通過使用Core提供的API來進行用戶身份的鑒權。
Koko是Go開發的組件,其ssh終端服務基于github.com/gliderlabs/ssh:
import "github.com/gliderlabs/ssh"
func NewSSHServer(jmsService *service.JMService) *Server {
...
sshHandler := handler.NewServer(termCfg, jmsService)
srv := &ssh.Server{
Addr: addr,
KeyboardInteractiveHandler: auth.SSHKeyboardInteractiveAuth,
PasswordHandler: sshHandler.PasswordAuth,
PublicKeyHandler: sshHandler.PublicKeyAuth,
...
}
return &Server{srv, sshHandler}
}
其中兩個配置函數PasswordHandler和PublicKeyHandler用于校驗用戶身份,用戶連接SSH端口并輸入密碼后,會調用PasswordHandler來驗證密碼是否正確,對于密鑰的認證則是使用PublicKeyHandler:
func (s *Server) PasswordAuth(ctx ssh.Context, password string) ssh.AuthResult {
ctx.SetValue(ctxID, ctx.SessionID())
tConfig := s.GetTerminalConfig()
if !tConfig.PasswordAuth {
logger.Info("Core API disable password auth auth")
return ssh.AuthFailed
}
sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService)
return sshAuthHandler(ctx, password, "")
}
func (s *Server) PublicKeyAuth(ctx ssh.Context, key ssh.PublicKey) ssh.AuthResult {
ctx.SetValue(ctxID, ctx.SessionID())
tConfig := s.GetTerminalConfig()
if !tConfig.PublicKeyAuth {
logger.Info("Core API disable publickey auth")
return ssh.AuthFailed
}
sshAuthHandler := auth.SSHPasswordAndPublicKeyAuth(s.jmsService)
value := string(gossh.MarshalAuthorizedKey(key))
return sshAuthHandler(ctx, "", value)
}
跟進可以發現,這兩個函數實際上都是使用HTTP請求Core組件提供的API來驗證用戶身份。那我們回到Core的代碼看看身份的驗證的過程是怎樣的。
默認情況下,Django使用數據庫中提供的User模型來驗證用戶,但開發者也可以編寫自己的用戶身份驗證后端,比如使用LDAP、OpenID等來實現統一身份認證協議。
在Jumpserver的配置中,我們可以看到它支持多個認證后端:
RBAC_BACKEND = 'rbac.backends.RBACBackend'
AUTH_BACKEND_MODEL = 'authentication.backends.base.JMSModelBackend'
AUTH_BACKEND_PUBKEY = 'authentication.backends.pubkey.PublicKeyAuthBackend'
AUTH_BACKEND_LDAP = 'authentication.backends.ldap.LDAPAuthorizationBackend'
AUTH_BACKEND_OIDC_PASSWORD = 'authentication.backends.oidc.OIDCAuthPasswordBackend'
AUTH_BACKEND_OIDC_CODE = 'authentication.backends.oidc.OIDCAuthCodeBackend'
AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend'
AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
AUTH_BACKEND_SSO = 'authentication.backends.sso.SSOAuthentication'
AUTH_BACKEND_WECOM = 'authentication.backends.sso.WeComAuthentication'
AUTH_BACKEND_DINGTALK = 'authentication.backends.sso.DingTalkAuthentication'
AUTH_BACKEND_FEISHU = 'authentication.backends.sso.FeiShuAuthentication'
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.sso.AuthorizationTokenAuthentication'
AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend'
AUTH_BACKEND_OAUTH2 = 'authentication.backends.oauth2.OAuth2Backend'
AUTH_BACKEND_TEMP_TOKEN = 'authentication.backends.token.TempTokenAuthBackend'
AUTH_BACKEND_CUSTOM = 'authentication.backends.custom.CustomAuthBackend'
AUTHENTICATION_BACKENDS = [
# 只做權限校驗
RBAC_BACKEND,
# 密碼形式
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_LDAP, AUTH_BACKEND_RADIUS,
# 跳轉形式
AUTH_BACKEND_CAS, AUTH_BACKEND_OIDC_PASSWORD, AUTH_BACKEND_OIDC_CODE, AUTH_BACKEND_SAML2,
AUTH_BACKEND_OAUTH2,
# 掃碼模式
AUTH_BACKEND_WECOM, AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU,
# Token模式
AUTH_BACKEND_AUTH_TOKEN, AUTH_BACKEND_SSO, AUTH_BACKEND_TEMP_TOKEN,
]
用戶在登錄時調用django.contrib.auth.authenticate(),Django內部會遍歷AUTHENTICATION_BACKENDS配置中所有的認證后端,逐一進行嘗試。如果第一個身份驗證方法失敗,Django 將嘗試第二個身份驗證方法,依此類推,直到嘗試完所有后端。
所以,只要某一個認證后端認證成功,用戶即可認證成功。
Koko在請求Core組件時,使用到的后端是authentication.backends.base.JMSModelBackend和authentication.backends.pubkey.PublicKeyAuthBackend。前者繼承傳統的數據庫認證后端,傳入賬號、密碼后進行校驗;后者則使用公鑰進行認證,相關代碼如下:
class JMSBaseAuthBackend:
...
def user_can_authenticate(self, user):
return True
class PublicKeyAuthBackend(JMSBaseAuthBackend):
...
def authenticate(self, request, username=None, public_key=None, **kwargs):
if not public_key:
return None
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
return None
else:
if user.check_public_key(public_key) and \
self.user_can_authenticate(user):
return user
class AuthMixin:
...
@staticmethod
def get_public_key_body(key):
for i in key.split():
if len(i) > 256:
return i
return key
def check_public_key(self, key):
if not self.public_key:
return False
key = self.get_public_key_body(key)
key_saved = self.get_public_key_body(self.public_key)
if key == key_saved:
return True
else:
return False
可見,PublicKeyAuthBackend后端認證時只要check_public_key和user_can_authenticate函數均返回true則認證成功,而這兩個函數的作用就是簡單的比對一下用戶傳入的公鑰是否和數據庫中保存的公鑰相等。
也就是說,對于公鑰認證后端PublicKeyAuthBackend來講,攻擊者只要知道一個用戶的用戶名和公鑰,即可通過PublicKeyAuthBackend后端的認證。
很離譜,我們來復現一下這個漏洞。首先,以正常用戶登錄控制臺,來到右上角個人中心->更新SSH密鑰頁面,將你的公鑰粘貼到頁面中保存:

這樣,你以后就可以直接通過公鑰認證的方式來登錄堡壘機SSH了。
然后我們直接發送包含用戶名和公鑰的請求給/api/v1/authentication/tokens/,可見已成功認證并返回用戶Token和詳細信息:

使用這個Token即可以用戶正常身份訪問所有有權限的API接口:

那么,最后一個問題,我們怎么獲得用戶的公鑰呢?既然是公鑰,理論上我們就可以認為是公開的。比如,我們可以通過Github拿到任意一個用戶的所有公鑰,如https://github.com/phith0n.keys:

CVE-2023-42818:SSH服務端認證繞過漏洞
經過對前一個漏洞的分析,我們已經大致了解了Jumpserver堡壘機的架構,其中最重要的兩個模塊就是Core和Koko,前者提供Web服務相關邏輯,后者提供SSH服務相關邏輯。
要準確理解本章節的內容,最好是先了解一下SSH握手認證的過程,但這里面涉及的篇幅過長,又可以單獨寫一篇文章來介紹了,所以只能略過詳情。我們只需知道一點,傳統SSH服務基于公鑰認證時,客戶端會將用戶的公鑰傳輸給服務端,服務端去~/.ssh/authorized_keys文件中匹配,如果能夠匹配上才會執行后續簽名驗證的過程。
在Jumpserver中,用戶使用ssh客戶端連接koko以后,koko會從ssh協議的握手包中取到用戶的公鑰,然后使用Core服務提供的API來校驗這個用戶的公鑰是否存在,此時Core服務提供的API取代的就是~/.ssh/authorized_keys文件的功能。
前面的CVE-2023-43652漏洞就是將Core的這個API給攻擊者任意調用所導致的,官方最后的修復方法是給公鑰認證后端PublicKeyAuthBackend中增加一層額外的Token頭校驗,只允許Koko來調用。
CVE-2023-42818漏洞和前面這個漏洞很像,都是攻擊者只需要知道目標用戶的公鑰即可偽造其身份,但其實原理還是不太一樣的。CVE-2023-42818漏洞的原理實際上是一個邏輯Bug,其核心出現在Koko依賴的一個庫https://github.com/LeeEirc/crypto中。
Koko為了實現SSH登錄的二次認證,魔改了下面兩個項目:
replace (
github.com/gliderlabs/ssh => github.com/LeeEirc/ssh v0.1.2-0.20220323091501-23b956e1e5a8
golang.org/x/crypto => github.com/LeeEirc/crypto v0.0.0-20230406074824-78021579524f
)
gliderlabs/ssh模塊用于啟動ssh服務,而簽名認證相關算法是在golang.org/x/crypto模塊中。
官方魔改這兩個庫代碼時引入了一處邏輯錯誤:當使用Core API校驗公鑰成功但用戶開啟二次認證(MFA)時,會返回一個ErrPartialSuccess錯誤,外部代碼遇到該錯誤則會進入到下一次驗證過程,也就是MFA;但對于golang.org/x/crypto模塊來講,只要遇到錯誤就不會進行私鑰簽名的校驗。
那么造成的一個后果就是,私鑰簽名認證不會執行,只要MFA的驗證能夠通過,則最后SSH認證就能夠成功。
要復現這個漏洞需要魔改自己的SSH客戶端,因為常規的SSH客戶端無法指定用戶公鑰,感興趣的朋友可以自己編寫代碼來實現這個POC。
CVE-2023-43651:MongoDB Proxy任意代碼執行漏洞
Jumpserver堡壘機除了提供SSH的管理外,還提供了各種其他服務的代理。管理員在后臺增加了這些資產后,用戶就可以在Koko的Web端連接并管理,和SSH類似。
對于Koko支持的服務,這里分為幾種情況:
-
如果是Telnet、SSH等服務,Koko將調用Go library來進行連接
-
如果是Magnus支持的數據庫,Jumpserver會使用Magnus提供的Web頁面來管理
-MySQL 5.7/8.0+
-MariaDB
- PostgreSQL (X-Pack)
- Oracle (X-Pack) -
對于其他數據庫(如MongoDB、redis、clickhouse等),Jumpserver會使用相對應的命令行客戶端來連接,并將標準輸入輸出重定向到Web端
-MongoDB使用
mongosh
-Redis使用redis-cli
-Clickhouse使用clickhouse-client
CVE-2023-43651漏洞原理其實很簡單,當Koko連接MongoDB時,會調用mongosh這個命令行工具。而mongosh提供的交互式控制臺中,支持直接使用JavaScript執行任意代碼,這部分代碼將會在客戶端也就是mongosh所在的機器上執行。
我們在本文前面也提到過下面這個很重要的概念:
在進入Jumpserver后臺以后,有很多地方可以用來執行命令或讀寫文件。但我們需要區分這個命令是在哪個機器上執行的:用戶推送命令到自己管理的主機中執行,這個是符合預期的行為,但是如果用戶在Jumpserver這臺堡壘機服務器上執行了任意命令或讀寫任意文件,這就是非預期的漏洞了。
這里很明顯是后者,所以是一個命令執行漏洞。
復現方法是,在Jumpserver后臺增加MongoDB資產并來到Web Terminal模塊連接數據庫,即可在交互式命令行中執行JavaScript代碼:
console.log(require("child_process").execSync("id").toString())

官方對于該漏洞的修復方式是限制Linux主機上各種命令的執行權限:

對于這個修復方法我只能說保留意見。
CVE-2023-42442:Session錄像任意下載漏洞
CVE-2023-42442漏洞是一個組合洞,包含Jumpserver中的兩個Bug:一是API未授權訪問導致泄露session信息;二是目錄權限繞過導致錄像文件被下載。
Jumpserver的Core模塊基于Django開發,其中包含兩種類型的后端視圖:
- 基于原生Django與模板渲染的視圖
- 基于Django Rest Framework(DRF)的API視圖
對于后者,Jumpserver直接使用了DRF的權限模型來驗證用戶權限。關于Django Rest Framework的權限體系的介紹,可以參考我2017年寫的一篇文章《從Pwnhub誕生聊Django安全編碼》。
DRF的Permission基礎權限類存在兩個接口:
has_permission判斷列表相關方法的權限has_object_permission判斷數據庫對象相關方法的權限
在Jumpserver中,IsSessionAssignee繼承了基礎權限類:
from rest_framework import permissions
class IsSessionAssignee(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
try:
return obj.ticket_relation.first().ticket.has_all_assignee(request.user)
except:
return False
但其只實現了has_object_permission函數,沒有實現has_permission函數。這意味著list相關與對象無關的方法將不會有權限校驗,可以直接被游客訪問。
全局搜索IsSessionAssignee,可見有一個視圖使用了這個類:
class SessionViewSet(OrgBulkModelViewSet):
model = Session
serializer_classes = {
'default': serializers.SessionSerializer,
'display': serializers.SessionDisplaySerializer,
}
search_fields = [
"user", "asset", "account", "remote_addr",
"protocol", "is_finished", 'login_from',
]
filterset_class = SessionFilterSet
date_range_filter_fields = [
('date_start', ('date_from', 'date_to'))
]
extra_filter_backends = [DatetimeRangeFilter]
rbac_perms = {
'download': ['terminal.download_sessionreplay']
}
permission_classes = [RBACPermission | IsSessionAssignee]
...
這個視圖用于展示所有連接過的session列表。比如,用戶不論從SSH還是從Web Terminal訪問堡壘機中的服務器,都會建立一個Session,我們可以通過這個接口來訪問:

可見成功拉取到session列表,并獲得所有詳情信息。
如果想要下載某個session的錄像,我們需要訪問media中對應的文件。在jumpserver/urls.py中可以看到靜態文件相關的路由:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += [
# Protect media
path('media/', include(private_storage.urls)),
]
這里使用了django-private-storage這個第三方模塊來管理靜態文件,這個模塊的作用就是保證靜態文件只允許被有權限的用戶下載訪問。其權限校驗相關回調函數在PRIVATE_STORAGE_AUTH_FUNCTION中,我們全局搜索一下這個配置:
PRIVATE_STORAGE_ROOT = MEDIA_ROOT
PRIVATE_STORAGE_AUTH_FUNCTION = 'jumpserver.rewriting.storage.permissions.allow_access'
跟進jumpserver.rewriting.storage.permissions.allow_access:
path_perms_map = {
'xpack': '*',
'settings': '*',
'replay': 'default',
'applets': 'terminal.view_applet',
'playbooks': 'ops.view_playbook'
}
def allow_access(private_file):
request = private_file.request
request_path = private_file.request.path
path_list = str(request_path)[1:].split('/')
path_base = path_list[1] if len(path_list) > 1 else None
path_perm = path_perms_map.get(path_base, None)
if not path_perm:
return False
if path_perm == '*' or request.user.has_perms([path_perm]):
return True
if path_perm == 'default':
return request.user.is_authenticated and request.user.is_staff
return False
分析這個函數的邏輯可以發現,這里是使用request.path來進行權限判斷,當path是以xpack/或settings/開頭的情況下,直接返回True。
那么,如果我們訪問xpack/../就可以繞過權限驗證,下載到replay文件,這是一個很經典的邏輯Bug:

不光可以下載到replay文件,只要是data/media/目錄下的文件都可以下載,比如applets下的代碼文件等:

總結
本文介紹了Jumpserver在9月份修復的數個安全漏洞,其中相對比較嚴重的是偽隨機數泄露導致的用戶劫持漏洞和Session錄像下載漏洞。利用前者我們可以修改任意用戶密碼,再集合playbook目錄穿越漏洞即可Getshell。
Jumpserver官方對于漏洞也還算公開透明,所有上述漏洞都申請了CVE編號并發布了漏洞告警,值得被部分國產軟件學習。
相信在閱讀本文后,你會對Jumpserver的架構有一定了解。文章中還留有一些坑等你來填,有興趣可以深入研究,并歡迎在『代碼審計』公眾號內分享。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/3043/
暫無評論