这意味着记忆安全语言是什么意思?

软件工程 2025-08-20

我们经常看到许多具有某种重叠功能的编程语言,例如静态类型或内存安全性,或更多。虽然第一个很容易解释,就像我们在编译时创建具有特定类型的变量一样,然后在运行时环境中,该变量的类型被保留并且无法更改,例如,如果这是编译时的整数,那么它仍然必须是运行时的整数。但是记忆安全呢?说这种X语言是记忆安全是什么意思?让我们看一下这篇文章中的一些示例,其中不同的语言可能具有自己的记忆安全方法。

在进行正式定义之前,我们可以想象一个情况,例如,我们为大小为4的整数数组分配了一些内存。假设我们在这里使用java,因此整数的大小将是4个字节,然后从本质上讲,我们的连续块为4 * 4 = 16个字节,用于该数组中的内存中,因为Java是内存的安全性,您可以执行此内存段中允许的所有操作,但是其他任何地方都可以使用!您可以访问第一个索引,可以更新最后一个索引的值,但是如果您尝试访问第5个索引(数组中不存在),那么我们在运行时会获得一些IndexOutOfBoundException ,这意味着该语言将阻止您访问您在开始时无法分配内存的位置。

让我们看另一个示例,我们将计算Java中一对字符串的NCD值:

float computeNCD(String a, String b) {
   Integer sizeA = compress(a);
   Integer sizeB = compress(b);
   Integer sizeBoth = compress(a + b);
   return (sizeBoth - Math.min(sizeA, sizeB)) / 
           (float)Math.max(sizeA, sizeB);
}

假设我们将在短期内使用不同的数据输入来调用此ComputencD函数10000次,一个潜在的问题是,在函数调用完成后,是否仍将内存分配给本地变量。在上面的摘要代码中,由于Java有一个垃圾收集器,因此不会发生这种情况,并且在引用它们后,它将释放那些未使用的本地变量的内存。如果将内存划分,则无法再引用它,这实质上是内存安全语言的第二个属性。

在非安全内存语言中,比如 C,你必须自己进行内存分配和释放,并在分配的内存被释放时取消引用变量,这样就违反了内存安全属性之一,在这种情况下,结果将不是确定性的,而是以多种不同的方式发生,例如立即崩溃、返回垃圾数据、看起来正常工作或损坏其他数据等……这是我们重写 NCD 公式的一个例子,但导致C中的内存安全问题:

float computeNCD(char* a, char* b) {
  int* sizeA = (int*)malloc(sizeof(int));
  int* sizeB = (int*)malloc(sizeof(int));

  *sizeA = compress(a);
  *sizeB = compress(b);
  
  char* combined = (char*)malloc(strlen(a) + strlen(b) + 1);
  strcpy(combined, a);
  strcat(combined, b);
  
  int* sizeBoth = (int*)malloc(sizeof(int));
  *sizeBoth = compress(combined);
   
  float result = (*sizeBoth - MIN(*sizeA, *sizeB)) / 
                 (float)MAX(*sizeA, *sizeB);

  free(sizeBoth);
  
  // USE AFTER FREE - accessing freed memory
  printf("Compressed value was: %dn", *compressedBoth);

  return result;
}

我们释放了分配给sizeBoth变量的内存,然后尝试再次访问此内存地址,如上所述,这可能会导致不同的不必要的行为,例如使程序崩溃,或者现在可能包含不同的数据。从技术上讲,这种访问称为“无使用”访问。攻击者可以利用这些“无使用”漏洞来在我们的系统上执行任意代码。

从正式的角度来看,当编程语言保证以本语言编写的程序中的所有内存访问均被明确定义,并且不能违反预期的内存模型,则将其视为“内存安全”。这意味着语言阻止:

  1. 空间内存安全性违规:在分配对象的边界之外访问内存(例如,访问不属于数组的索引)

  2. 时间内存安全违规:访问已经分配或尚未分配的记忆(例如,释放变量后访问该变量)

如前所述,您不能在Java中违反这些属性,但这可以用C/C ++等不安全的内存语言进行。

我们可能想知道的一个问题是,语言是否有垃圾收集器,那么这种语言记忆是安全的吗?答案是,这还不够,因为我们可以回顾一下上面的正式约束,垃圾收集器可以防止时间记忆安全,但并不一定保证了另一个属性也具有。作为一种记忆安全的语言,Java还防止了空间记忆安全性违反。它提供了实现此目的的全面功能列表,其中一些示例是自动检查阵列的界限,无需调查检查,无直接指针操作,自动字符串界限管理等。下面的示例中证明了一些功能:

// Bound-checking for arrays
int[] arr = new int[5]; 
try {
   arr[10] = 42; // Attempt to access out of bound index, will not succeed
} catch(ArrayIndexOutOfBoundsException e) {
  System.out.println("Prevented spatial memory violation: " + e.getMessage());
}

// Null-deference checking
String str = null;
try {
   int len = str.length(); // will throw NullPointerException
} catch(NullPointerException e) {
   System.out.println("Prevented null dereference: " + e.getMessage());
}

// No direct pointer manipulation

// In Java, this is impossible
// int[] arr = new int[10];
// int* ptr = arr + 5; // no pointer arithmetic 
// *(ptr + 10) = 42; // No ability to move outside of bounds


// Automatic string bound management

// In Java, you don't have to manually allocate the size for the string

String str1 = "Hello";
String str2 = ", World!";
// automatically allocate sufficient array size for string concatenation 
String helloWorld = str1 + str2; // no need to pre-allocate or manipulation the buffer size

有趣的是,尽管Java通过垃圾收集,自动存储器管理,界限和无主导的指针操纵来实现存储安全性,但Rust采用了根本不同的方法来实现内存安全性,在这种情况下,它将大多数支票从运行时转移到通过其创新所有权系统来编译时间。

Rust Memory安全功能在其内存模型中围绕这些关键概念旋转:

  1. 所有权:Rust中的每个值都有一个所有者变量。

  2. 借贷:根据某些严格的规则可以“借用”对价值的引用。

  3. 终身:编译器检查参考文献有效多长时间

这是Rust的所有权系统如何防止记忆安全问题:

fn main() {
   // Ownership example
   let s1 = String::from("hello"); // s1 owns the string
   let s2 = s1; // the ownership now moves to s2

   // this would cause a compile error, since s1 no longer owns string
   // println!("{}", s1);
   
   // Borrowing example
   let s3 = String::from("World");
   println(&s3);  // borrowing s3 (immutable reference)
   
   mutate_str(&s3); // error 
   
   // only can have one mutable borrow, prevents data-race condition
   mutate_mutable_str(mut &s3);
   
}

fn mutate_mutable_str(s: &mut String) {
  s.push_str("OK"); // compile just fine
}

fn mutate_str(s: &String) {
   s.push_str("!!!"); // compile error, cannot borrow *s as mutable
}

在这里,Rust的编译器分析了代码并确保:

  1. 没有使用后:搬到S2后尝试使用S1,将在编译时捕获。

  2. 没有悬空参考:参考文献不能超过(寿命更长),而不是他们所指的数据

  3. 没有数据竞赛:可以对同一数据有多个可变的参考

安全漏洞与内存安全问题有关

关键安全脆弱性示例:Heartbleed

现在,让我们浏览一些关键示例,其中攻击者可以利用程序的内存安全问题,从而可能导致暴露敏感信息。这是影响C OPENSL库编写的openssl库的简单版本,它本质上是一个读取的缓冲区,攻击者可以读取比预期的更多的内存,潜在地揭示诸如私钥之类的敏感信息:

// Vulnerable C code similar to Heartbleed
void process_heartbeat(unsigned char *request, int length) {
    // Request contains: [type(1 byte)][payload_length(2 bytes)][payload][...]
    unsigned short payload_length = *(unsigned short*)(request + 1);
    unsigned char* payload = request + 3; 

    // No validation that payload_length matches actual data length!
    
    unsigned char* response = malloc(3 + payload_length);
    response[0] = 1; // type;
    *(unsigned short*)(response + 1) = payload_length;
    
    // copying payload_length bytes regardless of the size of the actual data
    memcpy(response + 3, payload, payload_length);
    
    // send response...
}

此代码中的问题在于,它盲目信任payload_length将是请求数据的实际长度,并为有效负载分配了这一数量的内存。但是,恶意请求将发送比它发送的实际数据大的payload_lengthmemcpy函数将读取超出请求缓冲区的范围,并可能暴露出敏感的服务器内存。

有人会认为,这更可能是程序员的编程错误,我们可以使用该程序的其他一些内存安全版本。但是,这确实是一个内存安全问题,因为不需要的请求只能读取不应该的内存位置。由于C没有内置功能以进行界限检查,因此数组本身不会存储其长度。

我们可以重申,在Java中,很容易检查长度和访问距离内存,始终导致ArrayIndexOutOfBoundException ,而相同的操作可能会导致不同的行为:

  • 从内存的其他部分读取敏感数据(如

  • 覆盖临界记忆结构

  • 远程代码执行

  • 无声数据腐败

在Java,我们可以简单地进行一些检查:

void processHeartbeat(byte[] request) {
    if (request.length <  3) {
        throw new IllegalArgumentException("Request too short");
    }
    
    // Extract payload length (assuming big-endian)
    int payloadLength = ((request[1] & 0xFF) < <  8) | (request[2] & 0xFF);
    
    // Validate actual length
    if (payloadLength + 3  > request.length) {
        throw new IllegalArgumentException("Invalid payload length");
    }
    
    // Safe copy - Java ensures we can't read past the end of arrays
    byte[] payload = Arrays.copyOfRange(request, 3, 3 + payloadLength);
    
    // Create response...
}

另一个示例:远程代码执行(RCE)

另一种剥削技术是通过悬挂指针无使用后),当释放后访问内存时,有可能允许攻击者执行任意代码远程代码执行)。该技术在网络浏览器中特别危险,并且在许多零日攻击中都被利用。

这是此类攻击的简化示例:

class Document {
public:
    void removeElement(Element* element) {
        // remove element from the tree and free the memory
        delete element; 
        
        // Update counters, send events, etc...
        updateAfterRemoval();
    }

    void updateAfterRemoval() {
        // this might access to removed elements
        for(auto& observer: observers) {
           observer- >onElementRemoved(lastRemovedElement); 
        }
    }
    
    Element* lastRemovedElement;
    std::vector< Observer* > observers;
}

在此代码中,在删除元素后, lastRemovedElement可能是一个悬空的指针。如果观察者在通知事件中试图访问此问题,则它将访问释放内存,这可以由攻击者利用。

Java首先阻止这种行为发生:

class Document {
   private Element lastRemovedElement;
   private List< Observer > observers;

   public void removeElement(Element element) {
       // Store reference before removal
       lastRemovedElement = element;

       // Remove the element from the tree (but the memory is not immediately freed)
       removeFromTree(element);
       
       // Update and notify 
       updateAfterRemoval();

       // event after this method ends, the garbage collector won't free the element's memory. 
       // until there are no more references
   } 

   public void updateAfterRemoval() {
       // Safe to access lastRemovedElement here
       for(Observer observer: observers) {
           observer.onElementRemoved(lastRemovedElement);
       }
   }
}

Java中的垃圾收集器仅在不再存在对象的引用时收回内存,因为lastRemovedElement仍然具有参考,因此没有释放内存,从而阻止了无用的无使用问题。

我们为什么要关心这个记忆问题?

正如上面一些示例所示,内存问题是一个严重的问题,不仅仅是一些小型代码段示例。记忆安全问题的现实影响令人震惊:

  1. 微软:过去十年中Microsoft产品中所有漏洞的70%是记忆安全问题。

  2. Google Chrome :大约70%的高度安全错误是由内存安全问题引起的。

  3. Android :大约90%的安全漏洞与内存安全有关