openlang

free 전에 memset하면 기본 플래그에서는 통과하는데 OPT=-O2에서는 실패해요

C로 작은 credential holder를 만들고 있어요 (WSL, Neovim, 별거 없어요). vault_release()는 secret을 지운 다음 free하게 되어 있어요:

void vault_release(vault_entry *e)
{
    if (!e)
        return;

    memset(e->secret, 0, e->secret_len);
    free(e->secret);
    e->secret = NULL;
    free(e);
}

memset이 분명히 있고 길이도 전체예요. 세 번이나 읽어봤어요. 미묘한 부분이 아니에요.

테스트 tests/wipe_check.c는 secret 포인터를 저장해 두고, vault_release()를 호출한 다음, free된 블록을 읽어요. 네, UB인 거 알아요. 한 번 쓰고 버릴 테스트고, 저는 메모리에 뭐가 남아 있는지만 보고 싶어요. 앞의 16바이트는 glibc가 자기 bookkeeping을 거기에 쓰기 때문에 건너뛰어요.

그냥 make check를 하면 ok 줄이 나와요. 그다음 make release-check를 하면 clean하고 OPT=-O2로 다시 빌드해요:

release-check:
	$(MAKE) clean
	$(MAKE) check OPT=-O2

이러면 실패해요. FAIL 줄은 secret 바이트가 남아 있다고 하고, 직접 보면 앞의 16바이트만 덮어써져 있어요. 그 뒤로는 전부 원래 secret이 그대로 읽히는 상태로 남아 있어요.

소스도 같고 memset도 같아요. 기본 빌드는 제가 알기로는 이미 최적화된 상태예요, 그게 기본값이니까요. 그래서 -O2가 뭘 바꾸는지 모르겠어요. 그럼 어느 쪽일까요? glibc의 free()가 블록에 뭔가를 다시 쓰는 건가요, 아니면 제 -O2 타깃이 기본 빌드와 다른 걸 만드는 건가요? 생성된 어셈블리는 아직 안 봤어요.

"해제된 메모리를 읽지 마라"는 말씀을 하실 분들께: 알고 있어요. 묻는 질문에 답해 주세요.

영어에서 번역원문 보기

이어지는 대화 6

서로의 근거를 따라
  1. 고유 링크

    기본값이 최적화된 상태라는 건 잘못된 가정이에요. -O 플래그 없이 쓰는 GCC는 -O0이라서, 기본 빌드는 memset 호출을 그대로 두고 -O2 빌드는 없애요.

    앞의 16바이트는 glibc tcache의 next 포인터와 key고, free()가 쓰는 거예요. 그래서 그 부분만 바뀌는 거고요. 그 파일에 gcc -O2 -S를 돌려서 vault_release 안에 memset이 있는지 grep해 보세요. 아마 없을 거예요. 컴파일러는 free()가 객체의 수명을 끝낸다는 걸 알고 있어서 그 store를 dead store로 취급하거든요.

    일반 memset 대신 explicit_bzero()를 쓰세요. memset_s()가 있는 환경이면 그걸 써도 돼요.

    영어에서 번역원문 보기
  2. 고유 링크

    네, lathe 말이 맞아요. 제가 기본값을 거꾸로 알고 있었어요. -O 플래그가 없으면 -O0이니까 제 "기본" 빌드가 최적화 안 된 쪽이었고, store를 지우는 건 -O2뿐이에요. 컴파일러가 한 일을 glibc 탓으로 돌리고 있었어요.

    표준도 이걸 뒷받침해요. C11 5.1.2.3의 as-if 규칙에 따르면 컴파일러는 관찰 가능한 동작만 보존하면 돼요. free() 전까지 다시 읽히지 않는 객체에 대한 store는 관찰 가능하지 않으니 없어질 수 있어요. 제 테스트는 free 이후에 블록을 읽지만 그 읽기는 UB라서 컴파일러가 신경 쓸 의무가 없어요. 이 테스트는 처음부터 memset을 지켜줄 수 없었어요. 덮어써진 16바이트는 lathe가 말한 대로 free()가 자기 할 일을 한 것뿐이에요.

    해결책은 explicit_bzero()고, glibc에는 2.25부터 있어요. man 페이지는 여기예요: https://man7.org/linux/man-pages/man3/explicit_bzero.3.html

    memset_s는 Annex K라서 선택 사항이고, WSL에 있을 거라고는 정말 기대하지 않아요.

    vault.c에 gcc -O2 -S를 돌려서 memset이 사라졌는지 grep으로 확인하고, 호출을 바꾼 다음 make release-check를 다시 돌려볼게요. 그러면 두 단계 모두에서 테스트가 통과해야 해요.

    영어에서 번역원문 보기
  3. 고유 링크

    후속 질문이에요. 해제된 블록을 읽는 건 어차피 UB니까, -O2 빌드에서 wipe가 일어나는지 테스트하는 제대로 된 방법이 뭘까요? 제가 아직 갖고 있는 버퍼를 wipe하고 그걸 확인하는 건가요? 아니면 그냥 디스어셈블리를 읽는 건가요? 운 좋게 통과하기만 하는 테스트는 정말 만들고 싶지 않아요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. ack가 unbuffered receive인데도 -race가 DATA RACE를 보고해요. time.After 때문일까요?

    race detector가 틀렸거나, 제가 ack가 보장해 주는 걸 잘못 이해하고 있는 걸까요? pushgate라는 작은 서비스인데, 제 모바일 앱의 푸시 알림용 디바이스 토큰에 붙은 HMAC 서명을 검사해요. 호출하는 쪽이…

    답글 4최근 활동 go
  2. webhook 하나에서만 HMAC 불일치가 나요 (이름에 악센트가 들어간 것)

    저장해 둔 webhook 전송분을 다시 재생해서, 하위 단계에서 신뢰하기 전에 HMAC-SHA256을 검증하는 작은 Node 체커를 만들었어요 (순수 JS, 빌드 단계 없음). 세 개 중 두 개는 통과해요. billing-p…

    답글 7최근 활동 typescriptjavascript
  3. -O0과 -O2에서 같은 overflow 체크인데 에러 분기가 달라요

    count=65536, rec_size=32768인 악성 pack을 넣어봤어요. -O0에서는 "pack too large"가 나오는데, -O2에서는 "out of memory"가 나와요. 소스는 똑같은데 왜 이런지 도저히 모…

    답글 6최근 활동 c