StreamSec Home  Forum Home 
 
Welcome, Guest.
Your IP: 216.73.216.150
2026-09-25 22:43:03 
 Announcements
 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.
Bottom
 
Total posts: 1
 Author 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.
Top

:: Written with and Powered by StreamSec Tools 4.1 ::
Copyright © 2000-2026 StreamSec Handelsbolag
Forum data layer and page templates derived from the RealThinClient SDK web-forum example, Copyright © 2004-2022 Teppi Technology (MIT)