[하루한줄] CVE-2025-48062: Discourse의 Topic 초대 시 전송되는 이메일을 통한 HTML Injection 취약점

Target

  • Discourse < 3.4.5
  • Discourse < 3.5.0.beta6

Explain

background

Discourse는 오픈소스 기반 커뮤니티 플랫폼으로 GitLab, OpenAI 등에서 사용 중인 서비스입니다. CVE-2025-48062 취약점은 사용자에게 Topic 초대를 위한 이메일 전송 시 포함되는 내용이 별도의 필터링 없이 삽입되어 HTML Injection을 유발합니다.

root cause

새로운 Topic 초대 메일 생성 요청이 들어오면 라우팅 규칙에 따라 app/controllers/invites_controller.rb 파일의 create() 함수에서 처리됩니다.

# config/routes.rb

    resources :invites, only: %i[create update destroy]
    get "/invites/:id" => "invites#show", :constraints => { format: :html }
    post "invites/create-multiple" => "invites#create_multiple", :constraints => { format: :json }
    
# app/controllers/invites_controller.rb

  def create
    begin
      if params[:topic_id].present?
        topic = Topic.find_by(id: params[:topic_id]) # ----------> Find Topic by topic_id
        raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
        guardian.ensure_can_invite_to!(topic)
      end

# ...

      invite =
        Invite.generate( # ------------> Generate mail content
          current_user,
          email: params[:email],
          domain: params[:domain],
          skip_email: params[:skip_email],
          invited_by: current_user,
          custom_message: params[:custom_message],
          max_redemptions_allowed: params[:max_redemptions_allowed],
          topic_id: topic&.id,
          group_ids: groups&.map(&:id),
          expires_at: params[:expires_at],
          invite_to_topic: params[:invite_to_topic],
        )

이때 전달 받는 파라미터는 topic_idTopic.find_by() 호출을 통해 ActiveRecord 상에 일치하는 데이터가 존재하는지 확인합니다. Topic이 정상적으로 존재하는 경우 Invite.generate() 호출을 통한 email body 생성을 진행합니다.

위 흐름도는 컨트롤러부터 실제 body 내용을 작성하는 body() 함수까지의 전체적인 흐름입니다.

# lib/email/message_builder.rb

    def body
      body = nil

      if @opts[:template]
        body = I18n.t("#{@opts[:template]}.text_body_template", template_args).dup # --> Set body content
      else
        body = @opts[:body].dup
      end

      if @template_args[:unsubscribe_instructions].present?
        body << "\n"
        body << @template_args[:unsubscribe_instructions]
      end
      DiscoursePluginRegistry.apply_modifier(:message_builder_body, body, @opts, @to)
    end

함수를 살펴보면 별도의 필터링 과정 없이 @template_args 로 넘어온 값을 body 내용에 포함합니다. 마찬가지로 상위 단계에서 이를 호출할 때에도 유효한 데이터가 존재하는지 검증을 진행할 뿐, 악성 콘텐츠 포함 여부는 검증하지 않습니다.

# app/mailers/invite_mailer.rb

    if invite_to_topic && first_topic.present?
      # get topic excerpt
      topic_excerpt = ""
      topic_excerpt = first_topic.excerpt.tr("\n", " ") if first_topic.excerpt

      topic_title = first_topic.try(:title) # ----> Get topic_title
      if SiteSetting.private_email?
        topic_title = I18n.t("system_messages.private_topic_title", id: first_topic.id)
        topic_excerpt = ""
      end

patch diffing

3.5.0.beta6에서 적용된 패치를 확인하기 위해 3.5.0.beta5와 비교를 진행하면 commit 기록 중 “SECURITY: Escape topic title for mailers”를 확인할 수 있습니다. 해당 commit hash는 72e224b7627b410a00afdd3fb185b3523518dadc로 ‘message_builder.rb’와 ‘message_builder_spec.rb’ 파일에서 수정이 이루어졌습니다.

‘message_builder_spec.rb’ 파일은 구현된 기능이 올바르게 동작하는지 확인하기 위한 테스트 파일로 실제 구현에는 영향을 미치지 않습니다. 관련 내용은 RSpec에서 참고할 수 있습니다.

# lib/email/message_builder.rb
    def body
      body = nil

      if @opts[:template]
        template_args_to_escape = %i[topic_title inviter_name]

        template_args_to_escape.each do |key|
          next if !@template_args.key?(key)

          @template_args[key] = escaped_template_arg(key) # --> new mitigation function is called
        end

# ...

    private

    def escaped_template_arg(key) # -----> new mitigation function
      value = template_args[key].dup
      # explicitly escaped twice, as Mailers will mark the body as html_safe
      once_escaped = String.new(ERB::Util.html_escape(value))
      ERB::Util.html_escape(once_escaped)
    end

변경된 코드를 확인하면 body() 함수의 처리 과정 중 새로 추가된 escaped_template_arg() 함수를 호출하여 악의적인 내용이 포함되지 못하도록 수정되었습니다.

Exploit

requirements

공격자는 피해자에게 초대 메일을 보내기 위해 ActiveRecord 상에 유효한 토픽 내용이 존재해야 합니다. Discourse에서 토픽은 일반적으로 새로운 게시글을 만드는 것과 동일한 개념이기 때문에 만약 이 과정 중 특정 문자열에 대한 검사가 있는 경우 원활한 exploit이 불가합니다.

limitations

Topic 생성 과정을 확인하면 사이트 설정에 따른 최대 길이를 검증하는 로직이 존재합니다.

# app/assets/javascripts/discourse/app/services/composer.js

    if (
      opts.topicTitle &&
      opts.topicTitle.length <= this.siteSettings.max_topic_title_length
    ) {
      this.model.set("title", opts.topicTitle);
    }

따라서 payload 작성 시 길이 제한은 존재할 수 있지만 이외에 추가적인 검증은 확인되지 않았습니다.

Takeaways

최근 많은 브라우저나 메일 클라이언트의 경우 client-side 공격에 대한 보호를 제공하지만 위와 같은 공격은 불특정 다수에게 악성 메일을 전송하는 것으로 광범위한 피해를 입힐 수 있습니다. 이를 예방하기 위해 여러 프레임워크에서 escape 함수 또한 제공하는 만큼 적극 활용하는 것이 좋을 것 같습니다.

References



본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.