API-渗透测试指南-三-

52 阅读20分钟

API 渗透测试指南(三)

原文:annas-archive.org/md5/17aaacc96a6787faa93f98ca311ef149

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:API 的安全编码实践

欢迎来到本书的结尾,这也标志着你应用程序编程接口API)渗透测试之旅的开始!如果你从第一章开始阅读本书,我们已经一起走过了一段不短的路,涵盖并学习了 API 的不同方面,以其最广泛的形式,专注于渗透技术,但仍然关注应用程序所有者和开发者在发布 API 之前需要注意的事项。API 为世界打开了应用程序、服务和整个业务的大门。这扇门代表着软件的巨大责任,当然也可以扩展到支撑它的所有基础设施。

接下来的部分提供了在编码时构建 API 的建议。你将找到一些现代编程语言和技术的技巧和实践,这些语言和技术在创建 API 时应用得最广泛:Golang、GraphQL、Java、JavaScript 和 Python。书中讨论的所有主要问题都涵盖了。正如你可能已经知道的,安全是分层保护的概念。没有一种放之四海而皆准的方法。我们应该注意在编码时可能创造的攻击面。

这本书讲的是攻击,但它也足够道德,讨论了攻击的预防。说这些并不为过。归根结底,我们是安全专业人员,我们的主要目的是加强我们测试的软件,以减少入侵或数据泄漏的机会。

在本章中,我们将涵盖以下主题:

  • 安全编码实践的重要性

  • 实施安全的认证机制

  • 验证和清理用户输入

  • 实施适当的错误处理和异常管理

  • 数据保护和加密的最佳实践

技术要求

由于我们在本章中不会进行任何实际练习,因此没有技术要求。不过,如果你感觉有必要将代码付诸实践,随时欢迎。俗话说,熟能生巧。尽管去做,享受其中吧。

安全编码实践的重要性

我并不是想教你的祖母如何吸鸡蛋。完全不是。然而,正如我在亲自和写这本书时常说的那样,强调一些对某件事至关重要的概念和想法从来不会有坏处。将 API 安全编码付诸实践非常重要,因为 API 在软件开发中扮演着复杂且关键的角色。它们充当不同应用程序和服务之间的桥梁,使它们能够相互通信和交换数据。这个功能丰富的场景导致了某些 API 中嵌入的漏洞可能被探测(或者,更常见地说,被利用),从而允许未经授权的数据访问、权限提升、服务中断或系统被非法控制,有时还会导致数据勒索。因此,安全编码实践有助于通过增强 API 对常见威胁的防御能力,降低这些风险,如注入攻击(SQL 或 NoSQL)、跨站脚本XSS)和中间人攻击MitM)。

此外,这些实践共同帮助维持企业在客户中的信任与声誉。如今,数据泄露和安全事件可能对公司造成重大损害,包括但不限于财务损失、法律处罚(其中一些是由于合规机制)、以及公司声誉的裂痕。客户和常规用户不仅期望 API 提供的服务能顺利运行并始终可用,还期望他们的数据能够得到正确处理和保护。

公司可以采用一些安全编码方法来帮助他们建立一个体面的软件开发生命周期SDLC)。如果你还没有听说过,它只是一个在软件开发过程中适用的流程。这样的流程有多个阶段,如规划、设计、编码、测试、部署和维护。在 SDLC 的帮助下,软件会在每个阶段取得进展,从而提高项目管理的效率,并最终产生高质量的软件。这里列出了一些简单的 SDLC 方法:

  • 安全建设成熟度模型BSIMM):最初是软件保障成熟度模型SAMM)的一部分,BSIMM 已经从提供规范性指导转变为采用描述性方法,并定期更新以反映最新的最佳实践。BSIMM 并不建议采取具体的行动,而是概述了其成员组织的活动和实践。更多信息可以在www.synopsys.com/glossary/what-is-bsimm.html找到。

  • 微软安全开发生命周期SDL):这种指导性方法涵盖了广泛的安全问题,并为组织提供了关于实现更安全编码实践的指导。它有助于开发符合监管标准的软件,并有助于降低成本。更多信息请访问www.microsoft.com/en-us/securityengineering/sdl

  • OWASP 软件保障成熟度模型SAMM):SAMM 是一个开源倡议,采用指导性方法将安全性纳入 SDLC。它由 OWASP 维护,并得益于各种规模和行业的公司的贡献。更多信息请访问owasp.org/www-project-samm/

通过严格的编码实践展示对安全的承诺可以帮助与利益相关者建立和维护信任。它还向监管机构表明公司对遵守数据保护法律和行业标准非常认真,这可以避免后续的法律问题。另一个有用的资源是 OWASP 开发人员指南(owasp.org/www-project-developer-guide/),它提供了一个相当完整的定义和指南列表,介绍了如何普遍提高代码安全性。在编写本书时,该指南的版本是4.1.0。当然,永远不要忘记检查 OWASP 十大 API,可在owasp.org/API-Security/editions/2023/en/0x11-t10/找到。当前版本是 2023 年,详细介绍了 API 的十大最危险威胁。我们在第 1第 3章中讨论了它们。

最后,安全编码实践有助于确保软件系统的长期可持续性和可扩展性。随着应用程序的增长和演变,维护安全基础变得越来越复杂。早期采用安全编码实践有助于在开发团队内建立安全文化,使更容易识别和修复漏洞,避免它们成为重大问题。这种积极的安全方法可以通过减少对广泛安全补丁的需求和减轻潜在安全漏洞的影响来节省时间和资源。反过来,这将导致更稳定、更具弹性的应用程序,能够适应不断发展的数字环境中的新挑战和威胁。让我们开始讨论各种相关主题。

实施安全的身份验证机制

我们在第四章中讨论了针对安全身份验证机制的攻击。身份验证是 API 安全的关键组成部分,确保只有授权的用户才能访问受保护的资源。实现安全的身份验证机制需要仔细考虑各种因素。例如,在 Python 中,使用强大且独特的密码,并通过像 bcrypt 这样的模块进行哈希处理,可以显著增强安全性。避免以明文形式存储密码,或使用像 MD5 这样的弱哈希算法。在 Java 中,像 Spring Security 这样的库提供了强大的身份验证机制,包括对 OAuth2 和 JWT 的支持。不安全的实现可能直接接受用户凭据并返回令牌,而没有进行适当的验证,这样就容易受到攻击。相反,开发者应强制使用 bcrypt

# Insecure implementation
password = request.form['password']
user = authenticate(username, password)
# A more secure way of doing things
from bcrypt import hashpw, gensalt
password = request.form['password']
hashed_password = hashpw(password.encode('utf-8'), gensalt())
user = authenticate(username, hashed_password)

在 JavaScript 中,尤其是在 Node.js 环境中,使用像 Passport.js 这样的库可以有效地管理身份验证。然而,必须确保安全存储令牌,最好使用 HttpOnly cookies,以防止 XSS 攻击。类似地,在 Golang 中,使用像 gorilla/sessions 这样的中间件来安全处理会话管理是明智的。身份验证机制中的漏洞通常源于会话管理不当或令牌存储不安全。开发者应确保令牌定期轮换,并设置会话超时,以减少会话劫持的风险。在 GraphQL 中,确保限制查询的复杂度和深度,以防滥用。未能做到这一点可能会在错误消息中暴露敏感的用户细节,因此这些消息应该经过清理并保持最小化。以下 JavaScript 代码通过应用 httpOnly 来替代不安全的令牌存储方式:

// Insecure way of storing token in local storage
localStorage.setItem('token', token);
// Secure way of doing the same, but with HttpOnly cookies
res.cookie('token', token, { httpOnly: true, secure: true });

在接下来的部分中,我们将讨论如何正确地处理用户输入。

验证和清理用户输入

我们在第五章中讨论了利用用户输入的攻击。验证和清理用户输入是防止注入攻击的关键,诸如 SQL 注入、XSS 和命令注入等攻击都可以通过这一措施来避免。在 Python 中,像 DjangoFlask 这样的框架提供了内置的验证工具,但开发者必须确保正确使用它们。例如,依赖原始的 SQL 查询而不使用参数化输入可能导致 SQL 注入。相反,应使用对象关系映射器ORM)方法,这些方法可以自动处理参数化。以下 Python 代码展示了使用参数的细微差异:

# How you do an insecure SQL query
cursor.execute("SELECT * FROM users WHERE id = '%s'" % user_id)
# A secure approach by using parameterized queries
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

在 Java 中,使用像 Hibernate 这样的库可以通过使用Hibernate 查询语言HQL)或Java 持久化查询语言JPQL)来帮助防止注入攻击,这些语言在正确使用时天生是安全的。然而,开发者必须避免拼接字符串来构建查询。以下 Java 代码示例使用 HQL 替代原始或不安全的查询,并应用参数化查询:

// When you concatenate strings for SQL queries, you make them insecure
String query = "SELECT * FROM users WHERE id = " + userId;
List<User> users = entityManager.createNativeQuery(query, User.class).getResultList();
// Prefer instead using parameterized queries with HQL, for example
String query = "FROM User WHERE id = :userId";
List<User> users = entityManager.createQuery(query, User.class)
    .setParameter("userId", userId)
    .getResultList();

在 JavaScript 中,特别是使用 Node.js 时,开发者应使用如SequelizeMongoose这样的 ORM 库,它们支持带参数的查询。此外,像Joi这样的输入验证库可以帮助强制执行模式验证。然而,一个常见的错误是没有验证来自所有来源的输入,包括头部、cookie 和查询参数。请看下面的代码片段,它展示了如何使用Sequelize创建带参数的查询:

// This is insecure since it directly applies the user input
const userId = req.params.userId;
User.find({ where: { id: userId } });
// This is a parameterized query with Sequelize
const userId = req.params.userId;
User.find({ where: { id: Sequelize.literal('?'), replacements: [userId] } });

Golang 开发者应使用诸如validator等库来强制执行严格的输入验证规则。例如,错误的输入验证可能会直接将未经检查的用户输入传递到应用程序逻辑中,从而导致潜在的安全漏洞。相反,应该在处理之前严格地清理和验证所有输入。以下代码使用 Golang 的sql包向数据库发送带参数的查询。db变量也是通过该包生成的(使用sql.Open())。对于一个细心的读者(或安全审计员)来说,二者之间的区别非常微妙,但它在最终结果中的影响是显著的:

// Insecure: Directly using user input
userId := r.URL.Query().Get("user_id")
db.Query("SELECT * FROM users WHERE id = " + userId)
// Secure: Using parameterized queries with sql package
userId := r.URL.Query().Get("user_id")
db.Query("SELECT * FROM users WHERE id = ?", userId)

GraphQL 因其灵活的查询结构而在输入验证方面带来了独特的挑战。开发者应该定义严格的模式,并使用验证中间件来确保只处理有效的输入。例如,一个不安全的 GraphQL 端点可能会接受任意输入,从而导致资源耗尽或其他攻击。通过强制执行严格的类型定义和验证规则,开发者可以有效地减轻这些风险。接下来的 JavaScript 代码片段比较了不安全策略和安全策略。请注意,user是如何借助中间件在内部定义的:

// The insecure way: not validating input in GraphQL resolver
const resolvers = {
  Query: {
    user: (parent, args) => User.findById(args.id),
  },
};
// Here we make use of GraphQL middleware to reinforce protection
const { GraphQLObjectType, GraphQLString } = require('graphql');
const { GraphQLSchema, validateSchema } = require('graphql');
const userType = new GraphQLObjectType({
  name: 'User',
  fields: {
    id: { type: GraphQLString },
    name: { type: GraphQLString },
  },
});
const queryType = new GraphQLObjectType({
  name: 'Query',
  fields: {
    user: {
      type: userType,
      args: {
        id: { type: GraphQLString },
      },
      resolve: (parent, args) => {
        if (!args.id.match(/^[0-9a-fA-F]{24}$/)) {
          throw new Error('Invalid user ID format');
        }
        return User.findById(args.id);
      },
    },
  },
});
const schema = new GraphQLSchema({ query: queryType });
validateSchema(schema);

在下一部分中,你将学习如何正确处理错误和异常的最佳实践。

实现适当的错误处理和异常管理

我们在第六章中讨论了错误和异常处理不当的攻击。适当的错误处理和异常管理对于维护 API 的安全性和稳定性至关重要。在 Python 中,开发者应该使用try-except块优雅地处理异常,并避免将堆栈跟踪暴露给客户端。一个常见的缺陷是返回详细的错误信息,这些信息揭示了内部逻辑,攻击者可以利用这些信息进行攻击。相反,应该提供通用的错误信息,并在服务器端记录详细的错误日志。不要忘记定期轮换和加密这些日志。同时,限制只有具有合法理由的人和应用程序才能访问这些日志。以下代码块展示了两种处理异常的方法:

# Here you expose stack traces. Bad!
try:
    user = User.get(user_id)
except Exception as e:
    return str(e)
# Here you treat and hide internal error details
try:
    user = User.get(user_id)
except Exception as e:
    log.error(f"Error retrieving user: {e}")
    return "An error occurred"

Java 开发者可以利用 Spring 等框架提供的等效 try-catch 块和自定义异常处理机制,安全地管理错误。避免在异常消息中暴露敏感信息,并使用如 LogbackSLF4J 等日志框架安全地记录错误。下面的实现与之前的实现等效,但在 Java 中是这样写的:

// Do not expose internal details
try {
    User user = userService.findUserById(userId);
} catch (Exception e) {
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
// Instead, treat them and hide them
try {
    User user = userService.findUserById(userId);
} catch (Exception e) {
    log.error("Error retrieving user", e);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred");
}

在 JavaScript 中,使用 Express.js 中的全局错误处理 middleware 可以帮助捕获未处理的异常,并防止应用程序崩溃。然而,一个常见的错误是直接将错误记录到控制台,这可能构成安全风险。相反,应该使用安全的日志记录机制,并确保日志中不包含敏感信息。请看看如何实现:

// Do not log errors directly to the console
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send(err.message);
});
// Instead, prefer a logging library and hide error details
const winston = require('winston');
const logger = winston.createLogger({
  transports: [new winston.transports.File({ filename: 'error.log' })],
});
app.use((err, req, res, next) => {
  logger.error(err.stack);
  res.status(500).send('An error occurred');
});

Golang 开发者应使用defer-recover 模式来处理 panic,并确保应用程序不会意外崩溃。例如,一个不安全的实现可能会触发 panic,并在响应中暴露敏感数据。通过从 panic 中恢复并返回通用的错误信息,开发者可以增强安全性。请观察下方代码示例中使用延迟函数的两种方式,它们展示了 panic 消息的生成方式:

// Insecure way: Allowing panic to expose sensitive data
func handler(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if err := recover(); err != nil {
            fmt.Fprintf(w, "An error occurred: %v", err)
        }
    }()
    // Put here some code that could panic
}
// Secure way: Recovering from panic and hiding internal details
func handler(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("Recovered from panic: %v", err)
            http.Error(w, "An error occurred", http.StatusInternalServerError)
        }
    }()
    // Put here some code that could panic
}

最后,在 GraphQL 中,错误处理应该小心实现,以避免暴露内部架构的细节。使用自定义错误类和中间件来优雅地捕获和处理错误。不安全的 GraphQL 实现可能会返回详细的错误信息,暴露字段名称或其他架构细节,使得攻击者更容易构造恶意查询。通过实现适当的错误处理并清理错误信息,开发者可以保护他们的 API 免受攻击。接下来的 JavaScript 代码演示了这一点:

// Insecure form: Exposing detailed error messages in GraphQL
const resolvers = {
  Query: {
    user: (parent, args) => {
      throw new Error('Detailed error message with internal information');
    },
  },
};
// Secure form: Using custom error classes and middleware
class UserError extends Error {
  constructor(message) {
    super(message);
    this.name = 'UserError';
  }
}
const resolvers = {
  Query: {
    user: (parent, args) => {
      try {
        // Put here some code that may throw an error
      } catch (error) {
        throw new UserError('An error occurred');
      }
    },
  },
};

在接下来的部分,我们将讨论数据保护的最佳实践。

数据保护和加密的最佳实践

我们在第八章中讨论了通过未经授权的方式访问数据的攻击。数据保护和加密对于确保通过 API 传输的敏感信息至关重要。在 Python 中,使用如 cryptography 这样的库来加密静态和传输中的数据是至关重要的。例如,在将敏感信息如密码和个人数据存储到数据库之前进行加密,可以防止未经授权的访问。请观察下面的代码,它使用 cryptography 库来应用 Fernet 令牌和密钥:

# The wrong way: Storing sensitive data without encryption
user_data = {'ssn': '123-45-6789'}
database.store(user_data)
# The correct way: Encrypting sensitive data before storing
from cryptography.fernet import Fernet
key = Fernet.generate_key()
cipher_suite = Fernet(key)
encrypted_ssn = cipher_suite.encrypt(b'123-45-6789')
user_data = {'ssn': encrypted_ssn}
database.store(user_data)

在 Java 中,利用Java 加密架构JCA)提供了强大的加密机制。然而,开发者必须避免使用过时的加密算法,如 DES 或 RC4。相反,最好使用现代算法,如 AES,并采取适当的密钥管理措施。请观察接下来的示例:

// Insecure: Using flawed encryption algorithm
Cipher cipher = Cipher.getInstance("DES");
SecretKey key = KeyGenerator.getInstance("DES").generateKey();
cipher.init(Cipher.ENCRYPT_MODE, key);
// Secure: Using a more robust encryption algorithm (AES)
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKey key = KeyGenerator.getInstance("AES").generateKey();
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));

JavaScript 开发者应使用如 crypto 等库,在 Node.js 中安全地实现加密和解密操作。例如,一个不安全的实现可能会使用硬编码的加密密钥或弱加密算法。相反,应使用环境变量安全地存储密钥,并实现密钥轮换策略。请查看以下代码:

// Hardcoding encryption keys (bad!)
const crypto = require('crypto');
const key = 'hardcodedkey123';
const cipher = crypto.createCipher('aes-256-cbc', key);
// Using environment variables for encryption key (better!)
const key = process.env.ENCRYPTION_KEY;
const cipher = crypto.createCipher('aes-256-cbc', key);

在前面的代码片段中,JavaScript 代码利用环境变量存储一些敏感数据。这些数据可能由 .env 文件控制,这种方式被许多现代编程语言采用。该文件仅包含变量与其内容之间的关联,通常位于与源代码相同的目录下。当然,这不是最佳的解决方案,但它绝对比将密钥硬编码到逻辑中要好。另一个解决方案是,当你有一个密钥管理工具(无论是本地的还是由公有云提供的服务)时,将所有敏感数据存储在其中。这可以通过假设一个具有必要权限的角色来使用临时会话完成;你只需访问该管理工具并提取数据。

在 Golang 中,使用如 crypto/aes 等加密包并确保正确的密钥管理可以增强数据安全性。一个常见的缺陷是未能保护密钥或使用弱密钥,这可以通过遵循最佳的密钥管理实践来缓解。下面的摘录展示了这一点:

// Insecure: Using weak encryption key
block, err := aes.NewCipher([]byte("weakkey12345678"))
if err != nil {
    panic(err)
}
// Secure: Using strong encryption key
key := []byte("strongkey12345678901234567890")
block, err := aes.NewCipher(key)
if err != nil {
    panic(err)
}

GraphQL 对数据保护提出了独特的挑战,特别是在处理敏感查询和变更时。实现字段级加密并确保敏感数据在响应中返回之前被加密至关重要。例如,一个不安全的 GraphQL 实现可能在未加密的情况下返回敏感数据,从而暴露给潜在的拦截风险。通过加密敏感字段并使用安全的传输协议,如 HTTPS,开发者可以有效保护数据。以下 JavaScript 代码块展示了如何在正确加密后仅返回敏感数据:

// Bad way: Returning sensitive data without encryption
const resolvers = {
  Query: {
    user: (parent, args) => {
      return User.findById(args.id);
    },
  },
};
// Right way: Encrypting sensitive data before returning
const crypto = require('crypto');
const secret = process.env.SECRET_KEY;
const resolvers = {
  Query: {
    user: async (parent, args) => {
      const user = await User.findById(args.id);
      user.ssn = crypto.createHmac('sha256', secret)
                       .update(user.ssn)
                       .digest('hex');
      return user;
    },
  },
};

总之,API 的安全编码实践是构建稳健和安全的 API 和应用程序的基础。通过实现安全的身份验证机制、验证和清理用户输入、正确处理错误以及通过加密保护数据,开发者可以显著增强 API 的安全性。这些实践,结合持续的安全测试和监控,能够帮助减轻风险,并保护敏感信息免受潜在威胁。

正如我们在本书中多次看到的那样,并没有一种适用于所有情况的解决方案。没有单一的技术或原则可以保护整个 API。安全编码最佳实践是保护体系中的一个重要部分,但它们必须与安全的 API 架构设计相结合,并且在每次代码或数据流发生重大变化时,必须进行持续的监控和检查,触发常规验证。

总结

在这一章中,我们讨论了应该采取的重要措施,以避免在之前章节中涉及的不同方面发生重大事故。我们学习了如何更好地编写 API,以减少身份验证机制、用户输入、错误处理和异常管理以及数据保护中的风险。

总的来说,我们学到的是,利用广泛使用的开源库,这些库实现了安全机制或开放算法,结合一些实践,例如避免在逻辑中硬编码重要内容并持续监控活动。永远不要重复发明轮子。尽量避免晦涩的解决方案。最终,如果你、社区或合规机构都无法审计某个产品或服务,那么几乎不可能真正知道背后发生了什么,就像我们在这一章中学到的那样。

此外,我们还了解到,对于开发人员和开发经理来说,在公司内部讨论采纳安全编码方法论的可能性是很重要的。特别是当你完全不知道从何开始将 API 软件转变为更安全的产品时,这些方法尤其有用。

最后,我希望你和我一样喜欢阅读这本书。这是我的第一本书;希望这只是众多书籍中的第一本。

进一步阅读