A frame address stored into a global's field, array or Vec element, or pushed or put into a global container is refused with a fix that names no wrong type, and the sanitizer sweep builds at -O0 so a read of a returned frame reports

This commit is contained in:
Joseph Ferano 2026-09-25 21:18:48 +07:00
parent 4fa11f6360
commit ab5fe36a48
3 changed files with 92 additions and 22 deletions

View File

@ -3585,17 +3585,33 @@ let refuse_frame_escapes (f : Tast.fn) =
most one binding [Let] in the function. *) most one binding [Let] in the function. *)
let binds = Hashtbl.create 16 in let binds = Hashtbl.create 16 in
let unstable = Hashtbl.create 16 in let unstable = Hashtbl.create 16 in
(* A place a global owns, and so one that outlives every frame: the global,
a field or array element of it, or an element of a Vec it holds — the
Vec's block is the global's for as long as the global keeps it. A slice
is not stepped through: its storage may be anyone's. *)
let rec place_global = function let rec place_global = function
| Tast.Pglobal g -> Some g | Tast.Pglobal g -> Some g
| Tast.Pfield (t, _) -> expr_global t | Tast.Pfield (t, _) -> expr_global t
| Tast.Pindex (t, idx) when all_array t.Tast.ty idx -> expr_global t | Tast.Pindex (t, idx) when owned_levels t.Tast.ty idx -> expr_global t
(* [(set (at v i) x)] on a Vec is a store through the checked element
address [vec_at] builds. Any other pointer's target is not known to
be the global's. *)
| Tast.Pderef { Tast.e = Tast.Prim (Tast.Rt "flan_vec_at", t :: _); _ } ->
expr_global t
| _ -> None | _ -> None
and expr_global (e : Tast.expr) = and expr_global (e : Tast.expr) =
match e.Tast.e with match e.Tast.e with
| Tast.Global g -> Some g | Tast.Global g -> Some g
| Tast.Field (t, _) -> expr_global t | Tast.Field (t, _) -> expr_global t
| Tast.Prim (Tast.At, t :: idx) when all_array t.Tast.ty idx -> expr_global t | Tast.Prim (Tast.At, t :: idx) when owned_levels t.Tast.ty idx ->
expr_global t
| _ -> None | _ -> None
and owned_levels ty = function
| [] -> true
| _ :: rest ->
(match ty with
| Types.Array (_, el) | Types.Vec el -> owned_levels el rest
| _ -> false)
(* [(at a i j)] is one node carrying every index; each level stepped must (* [(at a i j)] is one node carrying every index; each level stepped must
be an array for the element to be inside [a]'s own bytes. *) be an array for the element to be inside [a]'s own bytes. *)
and all_array ty = function and all_array ty = function
@ -3765,24 +3781,46 @@ let refuse_frame_escapes (f : Tast.fn) =
^ ", instead of its address: drop the addr")) ^ ", instead of its address: drop the addr"))
(escapes 0 e) (escapes 0 e)
in in
(* Only a store into the global itself can be fixed by changing the
global's type; for a field, an element or a push, the value's type
belongs to something else. *)
let stored ~bare g v =
Option.iter
(fail ~verb:"stores" ~target:(" into the global " ^ g)
~fix_slice:(fun c ->
"Store a copy that outlives the frame: " ^ c
^ ", which puts the elements in the context allocator")
~fix_addr:(fun t ->
if bare then
"Store the value instead: declare " ^ g ^ " as " ^ t
^ " and drop the addr"
else "Store the value instead of its address"))
(escapes 0 v)
in
let returns = not (Types.equal f.Tast.ret Types.Unit) in let returns = not (Types.equal f.Tast.ret Types.Unit) in
List.iter List.iter
(Tast.walk (fun (e : Tast.expr) -> (Tast.walk (fun (e : Tast.expr) ->
match e.Tast.e with match e.Tast.e with
| Tast.Return (Some v) when returns -> tails v | Tast.Return (Some v) when returns -> tails v
| Tast.Set (p, v) when not (String.equal f.Tast.name "main") -> | Tast.Set (p, v) when not (String.equal f.Tast.name "main") ->
(match place_global p with Option.iter
| Some g -> (fun g ->
Option.iter stored ~bare:(match p with Tast.Pglobal _ -> true | _ -> false)
(fail ~verb:"stores" ~target:(" into the global " ^ g) g v)
~fix_slice:(fun c -> (place_global p)
"Store a copy that outlives the frame: " ^ c (* A push or put into a container a global owns: the element was
^ ", which puts the elements in the context allocator") bound to a slot first, and its address is what the call takes. *)
~fix_addr:(fun t -> | Tast.Prim (Tast.Rt ("flan_vec_push" | "flan_map_put"), target :: rest)
"Store the value instead: declare " ^ g ^ " as " ^ t when not (String.equal f.Tast.name "main") ->
^ " and drop the addr")) Option.iter
(escapes 0 v) (fun g ->
| _ -> ()) List.iter
(fun (a : Tast.expr) ->
match a.Tast.e with
| Tast.Prim (Tast.AddrOf, [ v ]) -> stored ~bare:false g v
| _ -> ())
rest)
(expr_global target)
| _ -> ())) | _ -> ()))
f.Tast.body; f.Tast.body;
if returns then if returns then

View File

@ -3405,6 +3405,29 @@ let () =
"(defonce g [i32]) \ "(defonce g [i32]) \
(defn f [] () (let [a [1 2 3] b [4 5]] (set g (slice a)) (set g (slice b))))" (defn f [] () (let [a [1 2 3] b [4 5]] (set g (slice a)) (set g (slice b))))"
~needle:"f stores a slice of a into the global g"; ~needle:"f stores a slice of a into the global g";
rejects_check "a local's address into a global array's element"
"(defonce gs [2 (Ptr i32)]) \
(defn f [] () (let [x (i32 1)] (set (at gs 0) (addr x))))"
~needle:"Store the value instead of its address";
rejects_check "a local's address into a global struct's field"
"(defstruct Q [p (Ptr i32)]) (defonce gq Q) \
(defn f [] () (let [x (i32 1)] (set (.p gq) (addr x))))"
~needle:"f stores the address of x into the global gq";
rejects_check "a local's address into a global Vec's element"
"(defonce gv (Vec (Ptr i32))) \
(defn f [] () (let [x (i32 1)] (set (at gv 0) (addr x))))"
~needle:"f stores the address of x into the global gv";
rejects_check "a local's address pushed onto a global Vec"
"(defonce gv (Vec (Ptr i32))) \
(defn f [] () (let [x (i32 1)] (push gv (addr x))))"
~needle:"f stores the address of x into the global gv";
rejects_check "a local's address put into a global Map"
"(defonce gm (Map i32 (Ptr i32))) \
(defn f [] () (let [x (i32 1)] (put gm 1 (addr x))))"
~needle:"f stores the address of x into the global gm";
accepts "a pointer parameter pushed onto a global Vec"
"(defonce gv (Vec (Ptr i32))) \
(defn f [p (Ptr i32)] () (push gv p))";
rejects_check "the fix keeps the slice's bounds" rejects_check "the fix keeps the slice's bounds"
"(defn mk [a [4 i32]] [i32] (slice a 1 3))" "(defn mk [a [4 i32]] [i32] (slice a 1 3))"
~needle:"(clone (slice a 1 3))"; ~needle:"(clone (slice a 1 3))";

View File

@ -64,7 +64,7 @@ let run exe args =
(try Sys.remove out with Sys_error _ -> ()); (try Sys.remove out with Sys_error _ -> ());
(code, text) (code, text)
let compile ?(dev = false) ~sanitize ~checks path = let compile ?(dev = false) ?(opt = "-O0") ~sanitize ~checks path =
let exe = let exe =
Filename.concat scratch Filename.concat scratch
(Printf.sprintf "flan-san-%s%s-%s" (Printf.sprintf "flan-san-%s%s-%s"
@ -73,10 +73,15 @@ let compile ?(dev = false) ~sanitize ~checks path =
(Filename.remove_extension (Filename.basename path))) (Filename.remove_extension (Filename.basename path)))
in in
let p, csrcs, lflags = Test_support.linked ~dev path in let p, csrcs, lflags = Test_support.linked ~dev path in
(* -O0 by default, both builds: detect_stack_use_after_return (see [env]) can only
report a read of a frame's variable that still has a stack slot. At -O2
the local is promoted to a register and the read folded to the value it
held, so a pointer into a returned frame reads nothing the fake stack
can poison. *)
ignore ignore
(Build.executable (Build.executable
~opts:{ Build.default with checks; sanitize; dev } ~csrcs ~lflags p ~opts:{ Build.default with checks; sanitize; dev; opt }
~out:exe); ~csrcs ~lflags p ~out:exe);
exe exe
let contains = Test_support.contains let contains = Test_support.contains
@ -107,6 +112,9 @@ let reported text = List.exists (contains text) markers
calc-me is here and is not in test/programs: it is the one string parser in calc-me is here and is not in test/programs: it is the one string parser in
the corpus, which makes it the likeliest to push [scratch] or [escaped] the corpus, which makes it the likeliest to push [scratch] or [escaped]
anywhere near their bounds. *) anywhere near their bounds. *)
(* temp-grow and condition-temp read text after a (free-temp) on purpose, to
show the dev poison, and a sanitized build reports exactly that read: they
stay out of this list. *)
let corpus = let corpus =
[ (* bounds.flan selects its case from argv, and every case but 0 is one [ (* bounds.flan selects its case from argv, and every case but 0 is one
that traps. Argument 0 is the in-bounds path — the last index of a that traps. Argument 0 is the in-bounds path — the last index of a
@ -327,7 +335,7 @@ let dyn_sweep () =
let p, csrcs, lflags = Test_support.linked path in let p, csrcs, lflags = Test_support.linked path in
match match
Build.executable Build.executable
~opts:{ Build.default with Build.sanitize = true } ~opts:{ Build.default with Build.sanitize = true; opt = "-O0" }
~csrcs:(csrcs @ [ "dyn_ops.c" ]) ~lflags p ~out:exe ~csrcs:(csrcs @ [ "dyn_ops.c" ]) ~lflags p ~out:exe
with with
| exception Failure m -> fail "dyn: sanitized build: %s" m | exception Failure m -> fail "dyn: sanitized build: %s" m
@ -439,8 +447,8 @@ let dev_sweep () =
sanitized run must produce ASan's report and must NOT produce the sanitized run must produce ASan's report and must NOT produce the
handler's line, and that is the assertion. handler's line, and that is the assertion.
And it has to be built at -O0. At the sweep's -O2 a store through a null And it has to be built at -O0, as the sweep now is. At -O2 a store through
pointer is undefined and need not fault — so a case that is about what happens a null pointer is undefined and need not fault — so a case that is about what happens
on a fault has to be compiled where the fault happens. Same family as the on a fault has to be compiled where the fault happens. Same family as the
-O0/-O2 split [unchecked_controls] records for bounds.flan. -O0/-O2 split [unchecked_controls] records for bounds.flan.
@ -592,7 +600,8 @@ let control ~expect_report ?(args = []) ~why name src =
wrong answer instead of touching a redzone. At -O0, with the load still in wrong answer instead of touching a redzone. At -O0, with the load still in
the program, ASan reports it. That divergence is why [--sanitize] does not the program, ASan reports it. That divergence is why [--sanitize] does not
force -O0 the way [--debug] does — the optimiser is half of what is being force -O0 the way [--debug] does — the optimiser is half of what is being
measured. *) measured — and why this one control is built at -O2 while the sweep is
not. *)
let unchecked_controls () = let unchecked_controls () =
let reports = [ "3"; "7"; "4" ] in let reports = [ "3"; "7"; "4" ] in
let silent = let silent =
@ -616,7 +625,7 @@ let unchecked_controls () =
here and now for a better reason. The trap itself is asserted in \ here and now for a better reason. The trap itself is asserted in \
test_acceptance.ml" ] test_acceptance.ml" ]
in in
match compile ~sanitize:true ~checks:false "programs/bounds.flan" with match compile ~opt:"-O2" ~sanitize:true ~checks:false "programs/bounds.flan" with
| exception Failure m -> fail "unchecked bounds.flan: build: %s" m | exception Failure m -> fail "unchecked bounds.flan: build: %s" m
| exe -> | exe ->
List.iter List.iter