|
| StreamSec Tools 4.1.2.355 - 4.1.3.359 - manual fix P01 The SFTP server follows a junction or symbolic link out of its root folder. | |
| Henrick Wibell Hellström 2026-09-25 19:48:38 Registered user |
WHAT GOES WRONG
The SFTP server subsystem confines every SFTP user to a root folder. The check every request's path goes through, tSFTPJail.MapToPhysical, answers from stFileJail.Resolve, which is lexical: it never looks at the disk. Only opening an existing file goes through the jail's check of what was actually opened; every other request acts on the path by name. A junction or symbolic link anywhere inside the served folder therefore takes SFTP users out of the root: they can list directories and see file details outside it, and remove, rename, create and truncate files there, with the rights of the server process. Reading an existing file outside the root is refused. The server refuses the SFTP request that creates links, so such a link has to be placed by someone with local write access to the served folder, or be there already. This holds on Windows, for junctions and symbolic links, and on Linux, for symbolic links. WHAT THE FIX DOES Before a request uses a path, the server checks on disk that the path - or, for a name that does not exist yet, its nearest existing parent folder - lies inside the root, by the rules the jail applies when it opens a file: on Windows every link is followed and what it reaches must lie inside the root; on Linux a symbolic link anywhere below the root is refused. A link whose target is gone is refused. A file the server creates or truncates is checked again once it is open. The check does not open ordinary files for reading, so a locked or unreadable file can still be listed and removed. WHO NEEDS IT Applications that serve SFTP with the SFTP server subsystem of these releases (StreamSec.SFTP.ServerSubSystem), on Windows or Linux. APPLIES TO StreamSec Tools 4.1.2.355, 4.1.3.356, 4.1.3.357, 4.1.3.358 and 4.1.3.359, in StreamSec.SFTP.ServerSubSystem.pas. The lines the fix touches are the same in all of them. 4.1.1.354 has a fix of its own. HOW TO APPLY - three edits in StreamSec.SFTP.ServerSubSystem.pas 1. In the implementation section, directly before the line constructor tSFTPJail.Create(const aPhysicalRoot: string); insert the routines of PART 1 below. 2. At the end of function tSFTPJail.MapToPhysical, find the lines aPhysical := IncludeTrailingPathDelimiter(fJail.Root) + StringReplace(Copy(lCanon,2,MaxInt),'/',PathDelim, [rfReplaceAll]); Result := True; and insert the lines of PART 2 between "[rfReplaceAll]);" and "Result := True;". 3. In procedure tSFTPServerSubSystem.DoOpen, find the lines of PART 3A and replace them with the lines of PART 3B. Then rebuild the packages, and the applications that serve SFTP. The fix is pure Pascal; no assembler is involved. -- PART 1 ---------------------------------------------------------------------- { ------------------------------------------ where a path really leads -- } { tSFTPJail.MapToPhysical answers from Resolve, which is LEXICAL: it cannot see a junction or a symbolic link, and the callers then act on the path by name. A link in the served tree - put there by anyone with local write access to it - would therefore take a request out of the root: to list, stat, remove, rename, make or remove a directory, or create or truncate a file. These look at the disk, by the jail's own rules: on Windows every link is followed and what was reached must lie inside the root; on POSIX a symbolic link anywhere below the root is refused. Neither opens an ordinary file for reading, so a locked or unreadable file stays within reach of stat and remove. } {$IFDEF MSWINDOWS} const cFinalPathFlags = FILE_NAME_NORMALIZED or VOLUME_NAME_DOS; function FinalPathOfHandle(aHandle: THandle; out aPath: string): Boolean; var lNeed: DWORD; lBuf: string; begin Result := False; aPath := ''; lNeed := GetFinalPathNameByHandleW(aHandle,nil,0,cFinalPathFlags); if (lNeed = 0) or (lNeed > 40000) then Exit; SetLength(lBuf,lNeed + 1); lNeed := GetFinalPathNameByHandleW(aHandle,PWideChar(lBuf),lNeed + 1, cFinalPathFlags); if (lNeed = 0) or (lNeed > DWORD(Length(lBuf))) then Exit; SetLength(lBuf,lNeed); aPath := lBuf; Result := True; end; { Opens aPath for its attributes only, following every link, and returns where it leads; aError is the Windows error when it cannot be opened. } function FinalPathOfName(const aPath: string; out aFinal: string; out aError: DWORD): Boolean; var lH: THandle; lPath: string; begin Result := False; aFinal := ''; lPath := aPath; if EndsText(':',lPath) then lPath := lPath + ''; // a drive's root, not the volume lH := CreateFileW(PWideChar(lPath),FILE_READ_ATTRIBUTES, FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE, nil,OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS,0); if lH = INVALID_HANDLE_VALUE then begin aError := GetLastError; Exit; end; aError := 0; try Result := FinalPathOfHandle(lH,aFinal); finally CloseHandle(lH); end; end; { aRoot is the jail's root as Windows reports it - the form the final paths come back in. } function InsideRoot(const aRoot, aFinal: string): Boolean; begin Result := SameText(aFinal,aRoot) or StartsText(IncludeTrailingPathDelimiter(aRoot),aFinal); end; { A name that does not exist yet - a file or directory about to be created, a rename target - is judged by its nearest existing parent. A name that exists but cannot be followed, such as a link whose target is gone, is refused, and so is anything Windows will not open for another reason. } function LeadsInsideRoot(const aRoot, aPhysical: string): Boolean; var lProbe, lFinal: string; lError: DWORD; begin Result := False; lProbe := aPhysical; while not FinalPathOfName(lProbe,lFinal,lError) do begin if ((lError <> ERROR_FILE_NOT_FOUND) and (lError <> ERROR_PATH_NOT_FOUND)) or (GetFileAttributes(PChar(lProbe)) <> INVALID_FILE_ATTRIBUTES) or (Length(lProbe) <= Length(aRoot)) then Exit; lProbe := ExtractFileDir(lProbe); end; Result := InsideRoot(aRoot,lFinal); end; { Where a file DoOpen has just created or truncated by name really is: its handle is asked, not its name. } function CreatedInsideRoot(const aRoot, aPhysical: string; aStream: TStream): Boolean; var lFinal: string; begin Result := (aStream is THandleStream) and FinalPathOfHandle(THandleStream(aStream).Handle,lFinal) and InsideRoot(aRoot,lFinal); end; {$ELSE} { Every name below the root, down to the first one that does not exist, must be a real object: a symbolic link is refused, as the jail's own walk refuses one - including a link whose target is gone. Nothing below a name that does not exist exists either, so the walk stops there. } function LeadsInsideRoot(const aRoot, aPhysical: string): Boolean; var lRootD, lPath, lPart: string; lSt: _stat; lZ: TBytes; begin Result := False; if aPhysical = aRoot then Exit(True); lRootD := IncludeTrailingPathDelimiter(aRoot); if not StartsStr(lRootD,aPhysical) then Exit; lPath := ExcludeTrailingPathDelimiter(aRoot); for lPart in Copy(aPhysical,Length(lRootD) + 1,MaxInt).Split([PathDelim]) do begin if lPart = '' then Continue; lPath := lPath + PathDelim + lPart; lZ := PathToPosixBytes(lPath); if lstat(MarshaledAString(@lZ[0]),lSt) <> 0 then Exit(errno = ENOENT); if S_ISLNK(lSt.st_mode) then Exit; end; Result := True; end; { No handle-to-name call here, so the name is walked again: a link put in the way since the first walk, and still there, is caught. } function CreatedInsideRoot(const aRoot, aPhysical: string; aStream: TStream): Boolean; begin Result := LeadsInsideRoot(aRoot,aPhysical); end; {$ENDIF} -- PART 2 ---------------------------------------------------------------------- { ... and it must lead inside the root ON DISK, which Resolve cannot see: a junction or symbolic link in the tree would take it out. } if not LeadsInsideRoot(fJail.Root,aPhysical) then begin aPhysical := ''; Exit; end; -- PART 3A - find these lines -------------------------------------------------- if lMode = fmCreate then { CREATION cannot be verified by opening - the object is not there yet, so there is nothing to interrogate. The jail has already confined the path, and the parent directory it resolved through was verified; this residual case is named in FileJail.txt rather than hidden. } lH.Stream := TFileStream.Create(lPhys,lMode) else if not fJail.OpenStream(lName,lWant,lH.Stream,lVerdict) then begin -- PART 3B - and replace them with these --------------------------------------- if lMode = fmCreate then begin { CREATION cannot be verified by opening - the object is not there yet, so there is nothing to interrogate beforehand. MapToPhysical has checked on disk that the path, or its nearest existing parent, lies inside the root, and what was created or truncated is checked again here, so a link put in the way since cannot redirect what is written. What such a link redirected before this check - a file created or truncated outside - is the residual race FileJail.txt names. } lH.Stream := TFileStream.Create(lPhys,lMode); if not CreatedInsideRoot(fJail.Root,lPhys,lH.Stream) then RaiseLastOSError({$IFDEF MSWINDOWS}ERROR_ACCESS_DENIED{$ELSE}EACCES{$ENDIF}); end else if not fJail.OpenStream(lName,lWant,lH.Stream,lVerdict) then begin -------------------------------------------------------------------------------- TESTED A test puts a junction (Windows) or a symbolic link (Linux) inside the served folder that leads out of it, and one whose target is gone, and serves a root folder that is itself reached through one. Unpatched, 4.1.2.355 and 4.1.3.359 map paths through both links to places outside the root - to read, to create a file, to make a directory - on Windows, and 4.1.3.359 on Linux too. Patched, both refuse every one of them and still map every ordinary path, on Win32, Win64 and Linux64. The SFTP self-test, which runs our own SFTP server and client against each other over SSH, passes with the fix in both releases. |